diff --git a/.dockerignore b/.dockerignore index 285200a8b127..f9fc785f92da 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,4 @@ -server/node_modules/ -server/.env \ No newline at end of file +.git +.env +node_modules +.nx/cache diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 000000000000..006b55e2a012 --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,151 @@ +module.exports = { + root: true, + extends: ['plugin:prettier/recommended'], + plugins: [ + '@nx', + 'prefer-arrow', + 'import', + 'simple-import-sort', + 'unused-imports', + 'unicorn', + ], + rules: { + 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], + 'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }], + 'no-control-regex': 0, + 'no-duplicate-imports': 'error', + 'no-undef': 'off', + 'no-unused-vars': 'off', + + '@nx/enforce-module-boundaries': [ + 'error', + { + enforceBuildableLibDependency: true, + allow: [], + depConstraints: [ + { + sourceTag: '*', + onlyDependOnLibsWithTags: ['*'], + }, + ], + }, + ], + + 'import/no-relative-packages': 'error', + 'import/no-useless-path-segments': 'error', + 'import/no-duplicates': ['error', { considerQueryString: true }], + + 'prefer-arrow/prefer-arrow-functions': [ + 'error', + { + disallowPrototype: true, + singleReturnOnly: false, + classPropertiesAllowed: false, + }, + ], + + 'simple-import-sort/imports': [ + 'error', + { + groups: [ + // Packages + ['^react', '^@?\\w'], + // Internal modules + ['^(@|~|src|@ui)(/.*|$)'], + // Side effect imports + ['^\\u0000'], + // Relative imports + ['^\\.\\.(?!/?$)', '^\\.\\./?$'], + ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], + // CSS imports + ['^.+\\.?(css)$'], + ], + }, + ], + 'simple-import-sort/exports': 'error', + + 'unused-imports/no-unused-imports': 'warn', + 'unused-imports/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + }, + overrides: [ + { + files: ['**/*.ts', '**/*.tsx'], + extends: ['plugin:@nx/typescript'], + rules: { + '@typescript-eslint/ban-ts-comment': 'error', + '@typescript-eslint/consistent-type-imports': [ + 'error', + { prefer: 'no-type-imports' }, + ], + '@typescript-eslint/explicit-function-return-type': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/interface-name-prefix': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-empty-interface': [ + 'error', + { + allowSingleExtends: true, + }, + ], + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/no-empty-function': 'off', + '@typescript-eslint/no-unused-vars': [ + 'warn', + { + vars: 'all', + varsIgnorePattern: '^_', + args: 'after-used', + argsIgnorePattern: '^_', + }, + ], + }, + }, + { + files: ['*.js', '*.jsx'], + extends: ['plugin:@nx/javascript'], + rules: {}, + }, + { + files: ['*.spec.@(ts|tsx|js|jsx)', '*.test.@(ts|tsx|js|jsx)'], + env: { + jest: true, + }, + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, + { + files: ['**/constants/*.ts', '**/*.constants.ts'], + rules: { + '@typescript-eslint/naming-convention': [ + 'error', + { + selector: 'variable', + format: ['UPPER_CASE'], + }, + ], + 'unicorn/filename-case': [ + 'warn', + { + cases: { + pascalCase: true, + }, + }, + ], + '@nx/workspace-max-consts-per-file': ['error', { max: 1 }], + }, + }, + { + files: ['*.json'], + parser: 'jsonc-eslint-parser', + }, + ], +}; diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 96425af5abda..000000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,126 +0,0 @@ -module.exports = { - root: true, - extends: ['plugin:prettier/recommended'], - plugins: [ - '@nx', - 'prefer-arrow', - 'simple-import-sort', - 'unused-imports', - 'unicorn', - ], - rules: { - 'func-style': ['error', 'declaration', { allowArrowFunctions: true }], - 'no-console': ['warn', { allow: ['group', 'groupCollapsed', 'groupEnd'] }], - 'no-unused-vars': 'off', - 'no-control-regex': 0, - 'no-undef': 'off', - - '@nx/enforce-module-boundaries': [ - 'error', - { - enforceBuildableLibDependency: true, - allow: [], - depConstraints: [ - { - sourceTag: '*', - onlyDependOnLibsWithTags: ['*'], - }, - ], - }, - ], - - 'prefer-arrow/prefer-arrow-functions': [ - 'error', - { - disallowPrototype: true, - singleReturnOnly: false, - classPropertiesAllowed: false, - }, - ], - - 'simple-import-sort/imports': [ - 'error', - { - groups: [ - ['^react', '^@?\\w'], - ['^(@|~)(/.*|$)'], - ['^\\u0000'], - ['^\\.\\.(?!/?$)', '^\\.\\./?$'], - ['^\\./(?=.*/)(?!/?$)', '^\\.(?!/?$)', '^\\./?$'], - ['^.+\\.?(css)$'], - ], - }, - ], - 'simple-import-sort/exports': 'error', - - 'unused-imports/no-unused-imports': 'warn', - 'unused-imports/no-unused-vars': [ - 'warn', - { - vars: 'all', - varsIgnorePattern: '^_', - args: 'after-used', - argsIgnorePattern: '^_', - }, - ], - }, - overrides: [ - { - files: ['**/*.ts', '**/*.tsx'], - extends: ['plugin:@nx/typescript'], - rules: { - '@typescript-eslint/ban-ts-comment': 'error', - '@typescript-eslint/interface-name-prefix': 'off', - '@typescript-eslint/explicit-function-return-type': 'off', - '@typescript-eslint/explicit-module-boundary-types': 'off', - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-unused-vars': [ - 'warn', - { - vars: 'all', - varsIgnorePattern: '^_', - args: 'after-used', - argsIgnorePattern: '^_', - }, - ], - '@typescript-eslint/consistent-type-imports': [ - 'error', - { prefer: 'no-type-imports' }, - ], - }, - }, - { - files: ['*.js', '*.jsx'], - extends: ['plugin:@nx/javascript'], - rules: {}, - }, - { - files: ['*.spec.@(ts|tsx|js|jsx)', '*.test.@(ts|tsx|js|jsx)'], - env: { - jest: true, - }, - rules: {}, - }, - { - files: ['**/constants/*.ts', '**/*.constants.ts'], - rules: { - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'variable', - format: ['UPPER_CASE'], - }, - ], - 'unicorn/filename-case': [ - 'warn', - { - cases: { - pascalCase: true, - }, - }, - ], - '@nx/workspace-max-consts-per-file': ['error', { max: 1 }], - }, - }, - ], -}; diff --git a/.eslintrc.react.cjs b/.eslintrc.react.cjs new file mode 100644 index 000000000000..22000fa33f96 --- /dev/null +++ b/.eslintrc.react.cjs @@ -0,0 +1,80 @@ +module.exports = { + extends: [ + 'plugin:@nx/react', + 'plugin:react/recommended', + 'plugin:react-hooks/recommended', + 'plugin:storybook/recommended', + ], + plugins: ['react-hooks', 'react-refresh'], + overrides: [ + { + files: ['*.ts', '*.tsx'], + parserOptions: { + project: ['./tsconfig.base.{json,*.json}'], + }, + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@tabler/icons-react'], + message: 'Please import icons from `twenty-ui`', + }, + { + group: ['react-hotkeys-web-hook'], + importNames: ['useHotkeys'], + message: + 'Please use the custom wrapper: `useScopedHotkeys` from `twenty-ui`', + }, + ], + }, + ], + '@nx/workspace-effect-components': 'error', + '@nx/workspace-no-hardcoded-colors': 'error', + '@nx/workspace-matching-state-variable': 'error', + '@nx/workspace-sort-css-properties-alphabetically': 'error', + '@nx/workspace-styled-components-prefixed-with-styled': 'error', + '@nx/workspace-no-state-useref': 'error', + '@nx/workspace-component-props-naming': 'error', + '@nx/workspace-explicit-boolean-predicates-in-if': 'error', + '@nx/workspace-use-getLoadable-and-getValue-to-get-atoms': 'error', + '@nx/workspace-useRecoilCallback-has-dependency-array': 'error', + 'react/no-unescaped-entities': 'off', + 'react/prop-types': 'off', + 'react/jsx-key': 'off', + 'react/display-name': 'off', + 'react/jsx-uses-react': 'off', + 'react/react-in-jsx-scope': 'off', + 'react/jsx-no-useless-fragment': 'off', + 'react/jsx-props-no-spreading': [ + 'error', + { + explicitSpread: 'ignore', + }, + ], + 'react-hooks/exhaustive-deps': [ + 'warn', + { + additionalHooks: 'useRecoilCallback', + }, + ], + }, + }, + { + files: ['*.stories.@(ts|tsx|js|jsx)'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, + { + files: ['.storybook/main.@(js|cjs|mjs|ts)'], + rules: { + 'storybook/no-uninstalled-addons': [ + 'error', + { packageJsonLocation: '../../package.json' }, + ], + }, + }, + ], +}; diff --git a/.github/vale-styles/docs/Numbers.yml b/.github/vale-styles/docs/Numbers.yml index 2a2c22943ac7..09a1d8aae7a0 100644 --- a/.github/vale-styles/docs/Numbers.yml +++ b/.github/vale-styles/docs/Numbers.yml @@ -1,6 +1,6 @@ extends: existence message: "Numbers must be spelled out" -level: error +level: warning ignorecase: true scope: paragraph tokens: diff --git a/.github/vale-styles/vocabularies/Base/accept.txt b/.github/vale-styles/vocabularies/Base/accept.txt new file mode 100644 index 000000000000..caf773c97515 --- /dev/null +++ b/.github/vale-styles/vocabularies/Base/accept.txt @@ -0,0 +1,14 @@ +kanban +kanbans +Kanban +Kanbans +automation +automations +Automation +Automations +S3 +PNGs +PNG +JPEG +JPEGs +US \ No newline at end of file diff --git a/.github/vale-styles/vocabularies/Base/reject.txt b/.github/vale-styles/vocabularies/Base/reject.txt new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/.github/workflows/cd-deploy-main.yaml b/.github/workflows/cd-deploy-main.yaml index 6b3837f92a59..9b6217c5ab30 100644 --- a/.github/workflows/cd-deploy-main.yaml +++ b/.github/workflows/cd-deploy-main.yaml @@ -13,3 +13,4 @@ jobs: token: ${{ secrets.TWENTY_INFRA_TOKEN }} repository: twentyhq/twenty-infra event-type: auto-deploy-main + client-payload: '{"github": ${{ toJson(github) }}}' # Passes the entire github context to the downstream workflow diff --git a/.github/workflows/cd-deploy-tag.yaml b/.github/workflows/cd-deploy-tag.yaml new file mode 100644 index 000000000000..17bee3d1a34f --- /dev/null +++ b/.github/workflows/cd-deploy-tag.yaml @@ -0,0 +1,16 @@ +name: CD deploy tag +on: + push: + tags: + - 'v*' +jobs: + deploy-tag: + runs-on: ubuntu-latest + steps: + - name: Repository Dispatch + uses: peter-evans/repository-dispatch@v2 + with: + token: ${{ secrets.TWENTY_INFRA_TOKEN }} + repository: twentyhq/twenty-infra + event-type: auto-deploy-tag + client-payload: '{"github": ${{ toJson(github) }}}' # Passes the entire github context to the downstream workflow diff --git a/.github/workflows/ci-chromatic.yaml b/.github/workflows/ci-chromatic.yaml index fa3364c572a3..fb0408e086eb 100644 --- a/.github/workflows/ci-chromatic.yaml +++ b/.github/workflows/ci-chromatic.yaml @@ -4,8 +4,18 @@ on: push: branches: - main + paths: + - 'package.json' + - 'packages/twenty-front/**' pull_request: types: [labeled] + paths: + - 'package.json' + - 'packages/twenty-front/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: chromatic-deployment: if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push' @@ -14,10 +24,6 @@ jobs: REACT_APP_SERVER_BASE_URL: http://127.0.0.1:3000 CHROMATIC_PROJECT_TOKEN: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v4 with: fetch-depth: 0 diff --git a/.github/workflows/ci-chrome-extension.yaml b/.github/workflows/ci-chrome-extension.yaml index d450cc4378f4..c50ecbf0cef5 100644 --- a/.github/workflows/ci-chrome-extension.yaml +++ b/.github/workflows/ci-chrome-extension.yaml @@ -3,7 +3,17 @@ 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-yarn-install: runs-on: ci-8-cores @@ -11,10 +21,6 @@ jobs: VITE_SERVER_BASE_URL: http://localhost:3000 VITE_FRONT_BASE_URL: http://localhost:3001 steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v3 diff --git a/.github/workflows/ci-docs.yaml b/.github/workflows/ci-docs.yaml index ad02f0724479..a08cd741a5c4 100644 --- a/.github/workflows/ci-docs.yaml +++ b/.github/workflows/ci-docs.yaml @@ -3,15 +3,21 @@ on: push: branches: - main + paths: + - 'package.json' + - 'packages/twenty-docs/**' pull_request: + paths: + - 'package.json' + - 'packages/twenty-docs/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: docs-build: runs-on: ubuntu-latest steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v3 @@ -35,4 +41,4 @@ jobs: token: ${{ github.token }} filter_mode: nofilter env: - REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }} \ No newline at end of file + REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }} diff --git a/.github/workflows/ci-front.yaml b/.github/workflows/ci-front.yaml index 66003014eab4..80e21acff652 100644 --- a/.github/workflows/ci-front.yaml +++ b/.github/workflows/ci-front.yaml @@ -3,17 +3,23 @@ on: push: branches: - main + paths: + - 'package.json' + - 'packages/twenty-front/**' pull_request: + paths: + - 'package.json' + - 'packages/twenty-front/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: front-yarn-install: runs-on: ci-8-cores env: REACT_APP_SERVER_BASE_URL: http://localhost:3000 steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v3 @@ -67,7 +73,7 @@ jobs: - name: Install Playwright run: cd packages/twenty-front && npx playwright install - name: Build Storybook - run: yarn nx storybook:pages:build --quiet twenty-front + run: yarn nx storybook:pages:build twenty-front --quiet - name: Run storybook tests run: | cd packages/twenty-front && npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ @@ -107,7 +113,7 @@ jobs: - name: Install Playwright run: cd packages/twenty-front && npx playwright install - name: Build Storybook - run: yarn nx storybook:modules:build --quiet twenty-front + run: yarn nx storybook:modules:build twenty-front --quiet - name: Run storybook tests run: | cd packages/twenty-front && npx concurrently -k -s first -n "SB,TEST" -c "magenta,blue" \ @@ -172,4 +178,4 @@ jobs: key: root-node_modules-${{hashFiles('yarn.lock')}} restore-keys: root-node_modules- - name: Front / Run jest - run: yarn nx test twenty-front \ No newline at end of file + run: yarn nx coverage twenty-front diff --git a/.github/workflows/ci-release.yaml b/.github/workflows/ci-release.yaml new file mode 100644 index 000000000000..2c28db36597f --- /dev/null +++ b/.github/workflows/ci-release.yaml @@ -0,0 +1,48 @@ +name: Release Twenty +on: + workflow_dispatch: + inputs: + version: + required: true + description: Version to release, without the v (e.g. 1.2.3) + ref: + default: main + description: Ref to start the release from (e.g. main, sha) + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + ref: ${{ github.event.inputs.ref }} + + - name: Sanitize version + id: sanitize + run: | + echo version=$(echo ${{ github.event.inputs.version }} | sed 's/^v//') >> $GITHUB_ENV + + - name: Update versions + working-directory: packages + run: | + for dir in twenty-docs twenty-emails twenty-front twenty-server twenty-ui twenty-website + do + cd $dir + npm version ${{ steps.sanitize.outputs.version }} --no-git-tag-version + cd .. + done + + # Make sure we have the latest changes before committing + - name: Pull changes + run: git pull --rebase + + - uses: stefanzweifel/git-auto-commit-action@v4 + with: + branch: ${{ github.event.inputs.ref }} + commit_message: "chore: release v${{ steps.sanitize.outputs.version }}" + tagging_message: v${{ steps.sanitize.outputs.version }} + + commit_user_name: Github Action Deploy + commit_user_email: github-action-deploy@twenty.com + commit_author: Github Action Deploy diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index c0782e11e614..b32016c8a625 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -3,7 +3,19 @@ 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-test: runs-on: ubuntu-latest @@ -16,10 +28,6 @@ jobs: ports: - 5432:5432 steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v3 @@ -30,6 +38,12 @@ jobs: - name: Server / Run linter run: yarn nx lint twenty-server - name: Server / Run jest tests - run: yarn nx test twenty-server - # - name: Server / Run e2e tests - # run: yarn nx test:e2e twenty-server + run: yarn nx test:unit twenty-server + - name: Server / Build + run: yarn nx build twenty-server + - name: Server / Write .env + run: | + cd packages/twenty-server + cp .env.example .env + - name: Worker / Run + run: MESSAGE_QUEUE_TYPE=sync yarn nx worker twenty-server diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml new file mode 100644 index 000000000000..1496425c8511 --- /dev/null +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -0,0 +1,59 @@ +name: 'Test Docker Compose' +on: + pull_request: + paths: + - 'packages/twenty-docker/**' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Run compose + run: | + echo "Patching docker-compose.yml..." + # change image to localbuild using yq + yq eval 'del(.services.server.image)' -i docker-compose.yml + yq eval '.services.server.build.context = "../../"' -i docker-compose.yml + yq eval '.services.server.build.dockerfile = "./packages/twenty-docker/twenty/Dockerfile"' -i docker-compose.yml + yq eval '.services.server.restart = "no"' -i docker-compose.yml + + yq eval 'del(.services.db.image)' -i docker-compose.yml + yq eval '.services.db.build.context = "../../"' -i docker-compose.yml + yq eval '.services.db.build.dockerfile = "./packages/twenty-docker/twenty-postgres/Dockerfile"' -i docker-compose.yml + + echo "Setting up .env file..." + 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 "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env + + echo "Starting server..." + docker compose up -d + docker compose logs db server -f & + pid=$! + + echo "Waiting for server to start..." + count=0 + while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do + sleep 1; + count=$((count+1)); + if [ $(docker inspect --format='{{.State.Status}}' twenty-server-1) = "exited" ]; then + echo "Server exited" + exit 1 + fi + if [ $count -gt 300 ]; then + echo "Failed to start server" + exit 1 + fi + done + working-directory: ./packages/twenty-docker/ diff --git a/.github/workflows/ci-utils.yaml b/.github/workflows/ci-utils.yaml index c1625bc120c9..e77c430a0592 100644 --- a/.github/workflows/ci-utils.yaml +++ b/.github/workflows/ci-utils.yaml @@ -2,7 +2,7 @@ name: CI Utils on: # it's usually not recommended to use pull_request_target # but we consider it's safe here if we keep the same steps - # see: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + # see: https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ # and: https://github.com/facebook/react-native/pull/34370/files pull_request_target: permissions: @@ -12,14 +12,14 @@ permissions: issues: write pull-requests: write statuses: write +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: danger-js: runs-on: ubuntu-latest steps: - - name: Cancel Previous Runs - uses: styfle/cancel-workflow-action@0.11.0 - with: - access_token: ${{ github.token }} - uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v3 diff --git a/.github/workflows/ci-website.yaml b/.github/workflows/ci-website.yaml new file mode 100644 index 000000000000..2bed055536ab --- /dev/null +++ b/.github/workflows/ci-website.yaml @@ -0,0 +1,29 @@ +name: CI Website +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: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: "18" + - name: Website / Install Dependencies + run: yarn + - name: Website / Build Website + run: yarn nx build twenty-website diff --git a/.gitignore b/.gitignore index 945b498ddc6d..fb8264316f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ **/**/.env .DS_Store -.idea/workspace.xml +/.idea **/**/node_modules/ # yarn is the recommended package manager across the project @@ -16,7 +16,10 @@ !.yarn/releases !.yarn/sdks !.yarn/versions -coverage .vercel -**/**/logs/** \ No newline at end of file +**/**/logs/** + +coverage +dist +storybook-static diff --git a/.nx/nxw.js b/.nx/nxw.js index a9af3c7e0307..2f4503c5aaa5 100644 --- a/.nx/nxw.js +++ b/.nx/nxw.js @@ -1,7 +1,7 @@ "use strict"; // This file should be committed to your repository! It wraps Nx and ensures // that your local installation matches nx.json. -// See: https://nx.dev/more-concepts/nx-and-the-wrapper for more info. +// See: https://nx.dev/recipes/installation/install-non-javascript for more info. @@ -11,9 +11,12 @@ const fs = require('fs'); const path = require('path'); const cp = require('child_process'); const installationPath = path.join(__dirname, 'installation', 'package.json'); -function matchesCurrentNxInstall(nxJsonInstallation) { +function matchesCurrentNxInstall(currentInstallation, nxJsonInstallation) { + if (!currentInstallation.devDependencies || + !Object.keys(currentInstallation.devDependencies).length) { + return false; + } try { - const currentInstallation = require(installationPath); if (currentInstallation.devDependencies['nx'] !== nxJsonInstallation.version || require(path.join(path.dirname(installationPath), 'node_modules', 'nx', 'package.json')).version !== nxJsonInstallation.version) { @@ -35,30 +38,58 @@ function ensureDir(p) { fs.mkdirSync(p, { recursive: true }); } } +function getCurrentInstallation() { + try { + return require(installationPath); + } + catch { + return { + name: 'nx-installation', + version: '0.0.0', + devDependencies: {}, + }; + } +} +function performInstallation(currentInstallation, nxJson) { + fs.writeFileSync(installationPath, JSON.stringify({ + name: 'nx-installation', + devDependencies: { + nx: nxJson.installation.version, + ...nxJson.installation.plugins, + }, + })); + try { + cp.execSync('npm i', { + cwd: path.dirname(installationPath), + stdio: 'inherit', + }); + } + catch (e) { + // revert possible changes to the current installation + fs.writeFileSync(installationPath, JSON.stringify(currentInstallation)); + // rethrow + throw e; + } +} function ensureUpToDateInstallation() { const nxJsonPath = path.join(__dirname, '..', 'nx.json'); let nxJson; try { nxJson = require(nxJsonPath); + if (!nxJson.installation) { + console.error('[NX]: The "installation" entry in the "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript'); + process.exit(1); + } } catch { - console.error('[NX]: nx.json is required when running the nx wrapper. See https://nx.dev/more-concepts/nx-and-the-wrapper'); + console.error('[NX]: The "nx.json" file is required when running the nx wrapper. See https://nx.dev/recipes/installation/install-non-javascript'); process.exit(1); } try { ensureDir(path.join(__dirname, 'installation')); - if (!matchesCurrentNxInstall(nxJson.installation)) { - fs.writeFileSync(installationPath, JSON.stringify({ - name: 'nx-installation', - devDependencies: { - nx: nxJson.installation.version, - ...nxJson.installation.plugins, - }, - })); - cp.execSync('npm i', { - cwd: path.dirname(installationPath), - stdio: 'inherit', - }); + const currentInstallation = getCurrentInstallation(); + if (!matchesCurrentNxInstall(currentInstallation, nxJson.installation)) { + performInstallation(currentInstallation, nxJson); } } catch (e) { diff --git a/.vscode/launch.json b/.vscode/launch.json index 31721f10ea5f..48983ed706f3 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,10 +7,11 @@ "type": "node", "request": "launch", "runtimeExecutable": "yarn", + "runtimeVersion": "18", "runtimeArgs": [ "nx", "run", - "twenty-server:start:dev", + "twenty-server:start", ], "outputCapture": "std", "internalConsoleOptions": "openOnSessionStart", diff --git a/.vscode/settings.json b/.vscode/settings.json index 8431fcdba733..299062d40ae7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,21 +4,21 @@ "[typescript]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, + "source.fixAll.eslint": "explicit", "source.addMissingImports": "always" } }, "[javascript]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, + "source.fixAll.eslint": "explicit", "source.addMissingImports": "always" } }, "[typescriptreact]": { "editor.formatOnSave": false, "editor.codeActionsOnSave": { - "source.fixAll.eslint": true, + "source.fixAll.eslint": "explicit", "source.addMissingImports": "always" } }, @@ -47,5 +47,6 @@ }, "search.exclude": { "**/.yarn": true, - } + }, + "eslint.debug": true } diff --git a/.vscode/twenty.code-workspace b/.vscode/twenty.code-workspace index 5abed2559c6c..581e3ed4c02d 100644 --- a/.vscode/twenty.code-workspace +++ b/.vscode/twenty.code-workspace @@ -42,10 +42,59 @@ }, ], "settings": { + "editor.formatOnSave": false, + "files.eol": "auto", + "[typescript]": { + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.addMissingImports": "always" + } + }, + "[javascript]": { + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.addMissingImports": "always" + } + }, + "[typescriptreact]": { + "editor.formatOnSave": false, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.addMissingImports": "always" + } + }, + "[json]": { + "editor.formatOnSave": true + }, + "javascript.format.enable": false, + "typescript.format.enable": false, + "cSpell.enableFiletypes": [ + "!javascript", + "!json", + "!typescript", + "!typescriptreact", + "md", + "mdx" + ], + "cSpell.words": [ + "twentyhq" + ], + "typescript.preferences.importModuleSpecifier": "non-relative", + "[javascript][typescript][typescriptreact]": { + "editor.codeActionsOnSave": { + "source.fixAll.eslint": "explicit", + "source.addMissingImports": "always" + } + }, + "search.exclude": { + "**/.yarn": true, + }, "files.exclude": { "packages/": true }, - "jest.autoEnable": false, + "jest.runMode": "on-demand", "jest.disabledWorkspaceFolders": [ "ROOT", "packages/twenty-zapier", diff --git a/README.md b/README.md index b61bd5073094..d76ededc46bc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ We felt the need for a CRM platform that empowers rather than constrains. We bel
# Demo -Go to app.twenty.com and login with the following credentials: +Go to demo.twenty.com and login with the following credentials: ``` email: noah@demo.dev password: Applecar2025 diff --git a/install.sh b/install.sh new file mode 100755 index 000000000000..a90f5866afa0 --- /dev/null +++ b/install.sh @@ -0,0 +1,167 @@ +#!/bin/bash + +echo "🔧 Checking dependencies..." +if ! command -v docker &>/dev/null; then + echo -e "\t❌ Docker is not installed or not in PATH. Please install Docker first.\n\t\tSee https://docs.docker.com/get-docker/" + exit 1 +fi +# Check if docker compose plugin is installed +if ! docker compose version &>/dev/null; then + echo -e "\t❌ Docker Compose is not installed or not in PATH (n.b. docker-compose is deprecated)\n\t\tUpdate docker or install docker-compose-plugin\n\t\tOn Linux: sudo apt-get install docker-compose-plugin\n\t\tSee https://docs.docker.com/compose/install/" + exit 1 +fi +# Check if docker is started +if ! docker info &>/dev/null; then + echo -e "\t❌ Docker is not running.\n\t\tPlease start Docker Desktop, Docker or check documentation at https://docs.docker.com/config/daemon/start/" + exit 1 +fi +if ! command -v curl &>/dev/null; then + echo -e "\t❌ Curl is not installed or not in PATH.\n\t\tOn macOS: brew install curl\n\t\tOn Linux: sudo apt install curl" + exit 1 +fi + +# Check if docker compose version is >= 2 +if [ "$(docker compose version --short | cut -d' ' -f3 | cut -d'.' -f1)" -lt 2 ]; then + echo -e "\t❌ Docker Compose is outdated. Please update Docker Compose to version 2 or higher.\n\t\tSee https://docs.docker.com/compose/install/linux/" + exit 1 +fi +# Check if docker-compose is installed, if so issue a warning if version is < 2 +if command -v docker-compose &>/dev/null; then + if [ "$(docker-compose version --short | cut -d' ' -f3 | cut -d'.' -f1)" -lt 2 ]; then + echo -e "\n\t⚠️ 'docker-compose' is installed but outdated. Make sure to use 'docker compose' or to upgrade 'docker-compose' to version 2.\n\t\tSee https://docs.docker.com/compose/install/standalone/\n" + fi +fi + +# Catch errors +set -e +function on_exit { + # $? is the exit status of the last command executed + local exit_status=$? + if [ $exit_status -ne 0 ]; then + echo "❌ Something went wrong, exiting: $exit_status" + fi +} +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} + +echo "🚀 Using version $version and branch $branch" + +dir_name="twenty" +function ask_directory { + read -p "📁 Enter the directory name to setup the project (default: $dir_name): " answer + if [ -n "$answer" ]; then + dir_name=$answer + fi +} + +ask_directory + +while [ -d "$dir_name" ]; do + read -p "🚫 Directory '$dir_name' already exists. Do you want to overwrite it? (y/N) " answer + if [ "$answer" = "y" ]; then + break + else + ask_directory + fi +done + +# Create a directory named twenty +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 +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 + +# Copy twenty/packages/twenty-docker/.env.example to .env +echo -e "\t• Setting up .env file" +curl -sLo .env https://raw.githubusercontent.com/twentyhq/twenty/$branch/packages/twenty-docker/.env.example + +# Replace TAG=latest by TAG= +if [[ $(uname) == "Darwin" ]]; then + # Running on macOS + sed -i '' "s/TAG=latest/TAG=$version/g" .env +else + # Assuming Linux + sed -i'' "s/TAG=latest/TAG=$version/g" .env +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 "" >>.env +echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env + +echo -e "\t• .env configuration completed" + +port=3000 +# Check if command nc is available +if command -v nc &> /dev/null; then + # Check if port 3000 is already in use, propose to change it + while nc -zv localhost $port &>/dev/null; do + read -p "🚫 Port $port is already in use. Do you want to use another port? (Y/n) " answer + if [ "$answer" = "n" ]; then + continue + fi + read -p "Enter a new port number: " new_port + if [[ $(uname) == "Darwin" ]]; then + sed -i '' "s/$port:$port/$new_port:$port/g" docker-compose.yml + else + sed -i'' "s/$port:$port/$new_port:$port/g" docker-compose.yml + fi + port=$new_port + done +fi + +# Ask user if he wants to start the project +read -p "🚀 Do you want to start the project now? (Y/n) " answer +if [ "$answer" = "n" ]; then + echo "✅ Project setup completed. Run 'docker compose up -d' to start." + exit 0 +else + echo "🐳 Starting Docker containers..." + docker compose up -d + # Check if port is listening + echo "Waiting for server to be healthy, it might take a few minutes while we initialize the database..." + # Tail logs of the server until it's ready + docker compose logs -f server & + pid=$! + while [ ! $(docker inspect --format='{{.State.Health.Status}}' twenty-server-1) = "healthy" ]; do + sleep 1 + done + kill $pid + echo "" + echo "✅ Server is up and running" +fi + +function ask_open_browser { + read -p "🌐 Do you want to open the project in your browser? (Y/n) " answer + if [ "$answer" = "n" ]; then + echo "✅ Setup completed. Access your project at http://localhost:$port" + exit 0 + fi +} + +# Ask user if he wants to open the project +# Running on macOS +if [[ $(uname) == "Darwin" ]]; then + ask_open_browser + + open "http://localhost:$port" +# Assuming Linux +else + # xdg-open is not installed, we could be running in a non gui environment + if command -v xdg-open >/dev/null 2>&1; then + ask_open_browser + + xdg-open "http://localhost:$port" + else + echo "✅ Setup completed. Your project is available at http://localhost:$port" + fi +fi diff --git a/nx.json b/nx.json index f2e89e086fa7..44fcf672ef61 100644 --- a/nx.json +++ b/nx.json @@ -2,38 +2,39 @@ "targetDefaults": { "build": { "cache": true, - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, "start": { "cache": true, - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, "lint": { "cache": true }, "test": { "cache": true, - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, "test:e2e": { "cache": true, - "dependsOn": [ - "^build" - ] + "dependsOn": ["^build"] }, - "@nx/jest:jest": { + "storybook:build": { "cache": true, "inputs": [ "default", "^default", - "{workspaceRoot}/jest.preset.js" - ], + "{projectRoot}/.storybook/**/*", + "{projectRoot}/tsconfig.storybook.json" + ] + }, + "storybook:dev": { + "cache": true, + "dependsOn": ["^build"] + }, + "@nx/jest:jest": { + "cache": true, + "inputs": ["default", "^default", "{workspaceRoot}/jest.preset.js"], "options": { "passWithNoTests": true }, @@ -44,28 +45,58 @@ } } }, - "@nx/vite:test": { + "@nx/eslint:lint": { "cache": true, "inputs": [ "default", - "^default" + "{workspaceRoot}/.eslintrc.js", + "{workspaceRoot}/tools/eslint-rules/**/*" ] + }, + "@nx/vite:test": { + "cache": true, + "inputs": ["default", "^default"] + }, + "@nx/vite:build": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["default", "^default"] } }, "installation": { - "version": "17.2.8" - }, - "affected": { - "defaultBase": "main" + "version": "18.1.3" }, "generators": { "@nx/react": { "application": { - "babel": true + "babel": true, + "style": "@emotion/styled", + "linter": "eslint", + "bundler": "vite", + "compiler": "swc", + "unitTestRunner": "jest", + "projectNameAndRootFormat": "derived" }, "library": { - "unitTestRunner": "none" + "style": "@emotion/styled", + "linter": "eslint", + "bundler": "vite", + "compiler": "swc", + "unitTestRunner": "jest", + "projectNameAndRootFormat": "derived" + }, + "component": { + "style": "@emotion/styled" + } + } + }, + "tasksRunnerOptions": { + "default": { + "options": { + "cacheableOperations": ["storybook:build"] } } - } -} \ No newline at end of file + }, + "useInferencePlugins": false, + "defaultBase": "main" +} diff --git a/package.json b/package.json index d0c8e0c6ce7b..0664fc64dd26 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,11 @@ "@apollo/server": "^4.7.3", "@aws-sdk/client-s3": "^3.363.0", "@aws-sdk/credential-providers": "^3.363.0", - "@blocknote/core": "^0.11.2", - "@blocknote/react": "^0.11.2", + "@blocknote/core": "^0.12.1", + "@blocknote/react": "^0.12.2", "@chakra-ui/accordion": "^2.3.0", "@chakra-ui/system": "^2.6.0", - "@codesandbox/sandpack-react": "^2.11.3", + "@codesandbox/sandpack-react": "^2.13.5", "@docusaurus/core": "^3.1.0", "@docusaurus/preset-classic": "^3.1.0", "@emotion/react": "^11.11.1", @@ -34,7 +34,7 @@ "@nestjs/jwt": "^10.0.3", "@nestjs/passport": "^9.0.3", "@nestjs/platform-express": "^9.0.0", - "@nestjs/serve-static": "^3.0.0", + "@nestjs/serve-static": "^4.0.1", "@nestjs/terminus": "^9.2.2", "@nestjs/typeorm": "^10.0.0", "@nivo/calendar": "^0.84.0", @@ -51,11 +51,14 @@ "@sentry/tracing": "^7.99.0", "@sniptt/guards": "^0.2.0", "@stoplight/elements": "^8.0.5", + "@storybook/icons": "^1.2.9", "@swc/jest": "^0.2.29", "@tabler/icons-react": "^2.44.0", + "@types/dompurify": "^3.0.5", "@types/facepaint": "^1.2.5", "@types/lodash.camelcase": "^4.3.7", "@types/lodash.merge": "^4.6.7", + "@types/lodash.pick": "^4.3.7", "@types/mailparser": "^3.4.4", "@types/nodemailer": "^6.4.14", "add": "^2.0.6", @@ -78,6 +81,7 @@ "debounce": "^2.0.0", "deep-equal": "^2.2.2", "docusaurus-node-polyfills": "^1.0.0", + "dompurify": "^3.0.11", "dotenv-cli": "^7.2.1", "drizzle-orm": "^0.29.3", "esbuild-plugin-svgr": "^2.1.0", @@ -85,7 +89,7 @@ "file-type": "16.5.4", "framer-motion": "^10.12.17", "googleapis": "105", - "graphiql": "^3.0.10", + "graphiql": "^3.1.1", "graphql": "16.8.0", "graphql-fields": "^2.0.3", "graphql-middleware": "^6.1.35", @@ -106,12 +110,15 @@ "libphonenumber-js": "^1.10.26", "lodash.camelcase": "^4.3.0", "lodash.debounce": "^4.0.8", + "lodash.groupby": "^4.6.0", "lodash.isempty": "^4.4.0", "lodash.isequal": "^4.5.0", "lodash.isobject": "^3.0.2", "lodash.kebabcase": "^4.1.1", + "lodash.mapvalues": "^4.6.0", "lodash.merge": "^4.6.2", "lodash.omit": "^4.5.0", + "lodash.pick": "^4.4.0", "lodash.snakecase": "^4.1.1", "lodash.upperfirst": "^4.3.1", "luxon": "^3.3.0", @@ -122,6 +129,8 @@ "next-mdx-remote": "^4.4.1", "nodemailer": "^6.9.8", "openapi-types": "^12.1.3", + "overlayscrollbars": "^2.6.1", + "overlayscrollbars-react": "^0.5.4", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", @@ -184,27 +193,34 @@ "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", - "@nx/eslint": "17.2.8", - "@nx/eslint-plugin": "17.2.8", - "@nx/jest": "17.2.8", - "@nx/js": "17.2.8", - "@nx/react": "17.2.8", - "@nx/vite": "17.2.8", + "@next/eslint-plugin-next": "^14.1.4", + "@nx/eslint": "18.1.3", + "@nx/eslint-plugin": "18.1.3", + "@nx/jest": "18.1.3", + "@nx/js": "18.1.3", + "@nx/react": "18.1.3", + "@nx/storybook": "18.1.3", + "@nx/vite": "18.1.3", + "@nx/web": "18.1.3", + "@sentry/types": "^7.109.0", "@storybook/addon-actions": "^7.6.3", "@storybook/addon-coverage": "^1.0.0", "@storybook/addon-essentials": "^7.6.7", "@storybook/addon-interactions": "^7.6.7", "@storybook/addon-links": "^7.6.7", "@storybook/addon-onboarding": "^1.0.10", - "@storybook/addon-themes": "^7.6.7", "@storybook/blocks": "^7.6.3", + "@storybook/core-server": "7.6.3", + "@storybook/jest": "^0.2.3", "@storybook/react": "^7.6.3", "@storybook/react-vite": "^7.6.3", "@storybook/test": "^7.6.3", "@storybook/test-runner": "^0.16.0", + "@storybook/testing-library": "^0.2.2", "@stylistic/eslint-plugin": "^1.5.0", - "@swc-node/register": "~1.6.7", + "@swc-node/register": "1.8.0", "@swc/core": "~1.3.100", + "@swc/helpers": "~0.5.2", "@testing-library/jest-dom": "^6.1.5", "@testing-library/react": "14.0.0", "@types/apollo-upload-client": "^17.0.2", @@ -219,15 +235,18 @@ "@types/js-cookie": "^3.0.3", "@types/lodash.camelcase": "^4.3.7", "@types/lodash.debounce": "^4.0.7", + "@types/lodash.groupby": "^4.6.9", "@types/lodash.isempty": "^4.4.7", "@types/lodash.isequal": "^4.5.7", "@types/lodash.isobject": "^3.0.7", "@types/lodash.kebabcase": "^4.1.7", + "@types/lodash.mapvalues": "^4.6.9", + "@types/lodash.omit": "^4.5.9", "@types/lodash.snakecase": "^4.1.7", "@types/lodash.upperfirst": "^4.3.7", "@types/luxon": "^3.3.0", "@types/ms": "^0.7.31", - "@types/node": "^20.10.6", + "@types/node": "18.19.26", "@types/passport-google-oauth20": "^2.0.11", "@types/passport-jwt": "^3.0.8", "@types/react": "^18.2.39", @@ -236,10 +255,12 @@ "@types/scroll-into-view": "^1.16.0", "@types/supertest": "^2.0.11", "@types/uuid": "^9.0.2", - "@typescript-eslint/eslint-plugin": "^6.10.0", - "@typescript-eslint/parser": "^6.10.0", - "@typescript-eslint/utils": "^6.9.1", + "@typescript-eslint/eslint-plugin": "6.21.0", + "@typescript-eslint/experimental-utils": "^5.62.0", + "@typescript-eslint/parser": "6.21.0", + "@typescript-eslint/utils": "6.21.0", "@vitejs/plugin-react-swc": "^3.5.0", + "@vitest/ui": "1.4.0", "chromatic": "^6.18.0", "concurrently": "^8.2.2", "cross-var": "^1.1.0", @@ -249,7 +270,7 @@ "eslint": "^8.53.0", "eslint-config-next": "14.0.4", "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.27.5", + "eslint-plugin-import": "2.29.1", "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.1.2", @@ -263,11 +284,12 @@ "http-server": "^14.1.1", "jest": "29.7.0", "jest-environment-jsdom": "29.7.0", + "jest-environment-node": "^29.4.1", "jest-fetch-mock": "^3.0.3", "jsdom": "~22.1.0", "msw": "^2.0.11", "msw-storybook-addon": "2.0.0--canary.122.b3ed3b1.0", - "nx": "17.2.8", + "nx": "18.1.3", "playwright": "^1.40.1", "prettier": "^3.1.1", "raw-loader": "^4.0.2", @@ -276,16 +298,18 @@ "storybook": "^7.6.3", "storybook-addon-cookie": "^3.2.0", "storybook-addon-pseudo-states": "^2.1.2", + "storybook-dark-mode": "^4.0.1", "supertest": "^6.1.3", "ts-jest": "^29.1.1", "ts-loader": "^9.2.3", "ts-node": "10.9.1", "tsconfig-paths": "^4.2.0", - "typescript": "^5.3.3", + "typescript": "5.3.3", "vite": "^5.0.0", "vite-plugin-checker": "^0.6.2", - "vite-plugin-dts": "~2.3.0", - "vite-plugin-svgr": "^4.2.0" + "vite-plugin-dts": "3.8.1", + "vite-plugin-svgr": "^4.2.0", + "vitest": "1.4.0" }, "engines": { "node": "^18.17.1", @@ -297,11 +321,12 @@ "packageManager": "yarn@4.0.2", "resolutions": { "graphql": "16.8.0", - "type-fest": "4.10.1" + "type-fest": "4.10.1", + "typescript": "5.3.3" }, "version": "0.2.1", "scripts": { - "start": "cross-env FORCE_COLOR=true concurrently -n \"twenty-server,twenty-front\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn nx start:dev twenty-server\" \"yarn nx start twenty-front\"" + "start": "cross-env FORCE_COLOR=true concurrently -n \"twenty-server,twenty-front\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn nx start twenty-server\" \"yarn nx start twenty-front\"" }, "workspaces": { "packages": [ @@ -310,6 +335,7 @@ "packages/twenty-docs", "packages/twenty-server", "packages/twenty-emails", + "packages/twenty-ui", "packages/twenty-utils", "packages/twenty-zapier", "packages/twenty-website", diff --git a/packages/twenty-chrome-extension/.eslintrc-ci.cjs b/packages/twenty-chrome-extension/.eslintrc-ci.cjs index 9b36dd30a212..a0b8a80632e8 100644 --- a/packages/twenty-chrome-extension/.eslintrc-ci.cjs +++ b/packages/twenty-chrome-extension/.eslintrc-ci.cjs @@ -3,17 +3,4 @@ module.exports = { rules: { 'no-console': 'error', }, - overrides: [ - { - files: [ - '.storybook/**/*', - '**/*.stories.tsx', - '**/*.test.ts', - '**/*.test.tsx', - ], - rules: { - 'no-console': 'off', - }, - }, - ], }; diff --git a/packages/twenty-chrome-extension/.eslintrc.cjs b/packages/twenty-chrome-extension/.eslintrc.cjs index fc82c221ab6f..8dbe0179d2ba 100644 --- a/packages/twenty-chrome-extension/.eslintrc.cjs +++ b/packages/twenty-chrome-extension/.eslintrc.cjs @@ -1,60 +1,14 @@ -// eslint-disable-next-line -const path = require('path'); - module.exports = { - extends: [ - 'plugin:@nx/react', - 'plugin:react/recommended', - 'plugin:react-hooks/recommended', - 'plugin:storybook/recommended', - '../../.eslintrc.js', - ], - plugins: ['react-hooks', 'react-refresh'], - ignorePatterns: ['!**/*', 'node_modules', 'dist'], - rules: { - '@nx/workspace-effect-components': 'error', - '@nx/workspace-no-hardcoded-colors': 'error', - '@nx/workspace-matching-state-variable': 'error', - '@nx/workspace-sort-css-properties-alphabetically': 'error', - '@nx/workspace-styled-components-prefixed-with-styled': 'error', - '@nx/workspace-no-state-useref': 'error', - '@nx/workspace-component-props-naming': 'error', - - 'react/no-unescaped-entities': 'off', - 'react/prop-types': 'off', - 'react/jsx-key': 'off', - 'react/display-name': 'off', - 'react/jsx-uses-react': 'off', - 'react/react-in-jsx-scope': 'off', - 'react/jsx-no-useless-fragment': 'off', - 'react/jsx-props-no-spreading': [ - 'error', - { - explicitSpread: 'ignore', - }, - ], - }, + extends: ['../../.eslintrc.cjs', '../../.eslintrc.react.cjs'], + ignorePatterns: ['!**/*', 'node_modules', 'dist', 'src/generated/*.tsx'], overrides: [ { - files: ['*.ts', '*.tsx', '*.js', '*.jsx'], + files: ['*.ts', '*.tsx'], parserOptions: { - project: ['packages/twenty-chrome-extension/tsconfig.*?.json'], - }, - rules: {}, - }, - { - files: ['.storybook/main.@(js|cjs|mjs|ts)'], - rules: { - 'storybook/no-uninstalled-addons': [ - 'error', - { packageJsonLocation: path.resolve('../../package.json') }, - ], + project: ['packages/twenty-chrome-extension/tsconfig.{json,*.json}'], }, - }, - { - files: ['.storybook/**/*', '**/*.stories.tsx', '**/*.test.@(ts|tsx)'], rules: { - 'no-console': 'off', + '@nx/workspace-explicit-boolean-predicates-in-if': 'warn', }, }, ], diff --git a/packages/twenty-chrome-extension/codegen.ts b/packages/twenty-chrome-extension/codegen.ts new file mode 100644 index 000000000000..30ed008a0d04 --- /dev/null +++ b/packages/twenty-chrome-extension/codegen.ts @@ -0,0 +1,24 @@ +import { CodegenConfig } from '@graphql-codegen/cli'; + +const config: CodegenConfig = { + schema: ['http://localhost:3000/graphql'], + overwrite: true, + documents: ['./src/**/*.ts', '!src/generated/**/*.*'], + generates: { + './src/generated/graphql.tsx': { + plugins: [ + 'typescript', + 'typescript-operations', + 'typescript-react-apollo', + ], + config: { + skipTypename: true, + withHooks: true, + withHOC: false, + withComponent: false, + }, + }, + }, +}; + +export default config; diff --git a/packages/twenty-chrome-extension/loading.html b/packages/twenty-chrome-extension/loading.html new file mode 100644 index 000000000000..b286e663d641 --- /dev/null +++ b/packages/twenty-chrome-extension/loading.html @@ -0,0 +1,12 @@ + + + + + + Twenty + + +
+ + + diff --git a/packages/twenty-chrome-extension/package.json b/packages/twenty-chrome-extension/package.json index c5f359cab037..237c74a514ad 100644 --- a/packages/twenty-chrome-extension/package.json +++ b/packages/twenty-chrome-extension/package.json @@ -10,6 +10,7 @@ "start": "yarn clean && vite", "build": "yarn clean && tsc && vite build", "lint": "eslint . --report-unused-disable-directives --max-warnings 0 --config .eslintrc.cjs", + "graphql:generate": "graphql-codegen", "fmt": "prettier --check \"src/**/*.ts\" \"src/**/*.tsx\"", "fmt:fix": "prettier --cache --write \"src/**/*.ts\" \"src/**/*.tsx\"" }, diff --git a/packages/twenty-chrome-extension/public/light-noise.png b/packages/twenty-chrome-extension/public/light-noise.png new file mode 100644 index 000000000000..d7b3bc2c064a Binary files /dev/null and b/packages/twenty-chrome-extension/public/light-noise.png differ diff --git a/packages/twenty-chrome-extension/src/background/index.ts b/packages/twenty-chrome-extension/src/background/index.ts index 36f2a1179613..271224ee05eb 100644 --- a/packages/twenty-chrome-extension/src/background/index.ts +++ b/packages/twenty-chrome-extension/src/background/index.ts @@ -8,8 +8,8 @@ chrome.runtime.onInstalled.addListener((details) => { }); // Open options page when extension icon is clicked. -chrome.action.onClicked.addListener(() => { - openOptionsPage(); +chrome.action.onClicked.addListener((tab) => { + chrome.tabs.sendMessage(tab.id ?? 0, { action: 'TOGGLE' }); }); // This listens for an event from other parts of the extension, such as the content script, and performs the required tasks. diff --git a/packages/twenty-chrome-extension/src/contentScript/createButton.ts b/packages/twenty-chrome-extension/src/contentScript/createButton.ts index 990d4e4e8037..1fa9e9a3f154 100644 --- a/packages/twenty-chrome-extension/src/contentScript/createButton.ts +++ b/packages/twenty-chrome-extension/src/contentScript/createButton.ts @@ -1,42 +1,55 @@ -/* eslint-disable @nx/workspace-no-hardcoded-colors */ const createNewButton = ( text: string, onClickHandler: () => void, -): HTMLButtonElement => { - const newButton: HTMLButtonElement = document.createElement('button'); - newButton.textContent = text; +): HTMLDivElement => { + const div = document.createElement('div'); + const img = document.createElement('img'); + const span = document.createElement('span'); + + span.textContent = text; + img.src = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAA7EAAAOxAGVKw4bAAACb0lEQVR4nO2VO4taQRTHr3AblbjxEVlwCwVhg7BoqqCIjy/gAyyFWNlYBOxsfH0KuxgQGwXRUkGuL2S7i1barGAgiwbdW93SnGOc4BonPiKahf3DwXFmuP/fPM4ZlvmlTxAhCBdzHnEQWYiv7Mr4C3NeuVYhQYDPzOUUQgDLBQGcLHNhvQK8DACPx8PTxiqVyvISG43GbyaT6Qfpn06n0m63e/tPAPF4vJ1MJu8kEsnWTCkWi1yr1RKGw+GDRqPBOTfr44vFQvD7/Q/lcpmaaVQAr9fLp1IpO22c47hGOBz+MB6PH+Vy+VYDAL8qlUoGtVotzOfzq4MAgsHgE/6KojiQyWR/bKVSqbSszHFM8Pl8z1YK48JsNltCOBwOnrYLO+8AAIjb+nHbycoTiUQfDJ7tFq4YAHiVSmXBxcD41u8flQU8z7fhzO0r83atVns3Go3u9Xr9x0O/RQXo9/tsIBBg6vX606a52Wz+bZ7P5/WwG29gxSJzhKgA6XTaDoFNF+krFAocmC//4yWEcSf2wTm7mCO19xFgSsKOLI16vV7b7XY7mRNoLwA0JymJ5uQIzgIAuX5PzDElT2m+E8BqtQ4ymcx7Yq7T6a6ZE4sKgOadTucaCwkxp1UzlEKh0GDxIXOwDWHAdi6Xe3swQDQa/Q7mywoolUpvsaptymazDWKxmBHTlWXZm405BFZoNpuGgwEmk4mE2SGtVivii4f1AO7J3ZopkQCQj7Ar1FeRChCJRJzVapX6DKNIfSc1Ax+wtQWQ55h6bH8FWDfYV4fO3wlwDr0C/BcADYiTPCxHqIEA2QsCZAkAKnRGkMbKN/sTX5YHPQ1e7SkAAAAASUVORK5CYII='; + img.height = 16; + img.width = 16; + img.alt = 'Twenty logo'; // Write universal styles for the button - const buttonStyles = { + const divStyles = { border: '1px solid black', borderRadius: '20px', backgroundColor: 'black', color: 'white', - fontSize: '1.5rem', fontWeight: '600', - padding: '0.45em 1em', - width: '15rem', + fontSize: '1.5rem', + display: 'flex', + alignItems: 'center', + gap: '5px', + justifyContent: 'center', + padding: '0 1rem', + cursor: 'pointer', height: '32px', }; - // Apply common styles to the button. - Object.assign(newButton.style, buttonStyles); + Object.assign(div.style, divStyles); - // Apply common styles to specifc states of a button. - newButton.addEventListener('mouseenter', () => { - const hoverStyles = { - backgroundColor: '#5e5e5e', - borderColor: '#5e5e5e', - }; - Object.assign(newButton.style, hoverStyles); - }); + // // Apply common styles to the button. + // Object.assign(buttonDiv.style, buttonDivStyles); - newButton.addEventListener('mouseleave', () => { - Object.assign(newButton.style, buttonStyles); - }); + // // Apply common styles to specifc states of a button. + // newButton.addEventListener('mouseenter', () => { + // const hoverStyles = { + // backgroundColor: '#5e5e5e', + // borderColor: '#5e5e5e', + // }; + // Object.assign(newButton.style, hoverStyles); + // }); + + // newButton.addEventListener('mouseleave', () => { + // Object.assign(newButton.style, buttonStyles); + // }); // Handle the click event. - newButton.addEventListener('click', async () => { + div.addEventListener('click', async () => { const { apiKey } = await chrome.storage.local.get('apiKey'); // If an api key is not set, the options page opens up to allow the user to configure an api key. @@ -46,13 +59,16 @@ const createNewButton = ( } // Update content during the resolution of the request. - newButton.textContent = 'Saving...'; + span.textContent = 'Saving...'; // Call the provided onClickHandler function to handle button click logic onClickHandler(); }); - return newButton; + div.appendChild(img); + div.appendChild(span); + + return div; }; export default createNewButton; diff --git a/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts b/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts index e2c972efa164..5096438a0557 100644 --- a/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts +++ b/packages/twenty-chrome-extension/src/contentScript/extractCompanyProfile.ts @@ -1,10 +1,10 @@ import createNewButton from '~/contentScript/createButton'; import extractCompanyLinkedinLink from '~/contentScript/utils/extractCompanyLinkedinLink'; import extractDomain from '~/contentScript/utils/extractDomain'; -import handleQueryParams from '~/utils/handleQueryParams'; -import requestDb from '~/utils/requestDb'; +import { createCompany, fetchCompany } from '~/db/company.db'; +import { CompanyInput } from '~/db/types/company.types'; -const insertButtonForCompany = (): void => { +const insertButtonForCompany = async (): Promise => { // Select the element in which to create the button. const parentDiv: HTMLDivElement | null = document.querySelector( '.org-top-card-primary-actions__inner', @@ -12,94 +12,111 @@ const insertButtonForCompany = (): void => { // Create the button with desired callback funciton to execute upon click. if (parentDiv) { - const newButtonCompany: HTMLButtonElement = createNewButton( - 'Add to Twenty', - async () => { - // Extract company-specific data from the DOM - const companyNameElement = document.querySelector( - '.org-top-card-summary__title', - ); - const domainNameElement = document.querySelector( - '.org-top-card-primary-actions__inner a', - ); - const addressElement = document.querySelectorAll( - '.org-top-card-summary-info-list__info-item', - )[1]; - const employeesNumberElement = document.querySelectorAll( - '.org-top-card-summary-info-list__info-item', - )[3]; + // Extract company-specific data from the DOM + const companyNameElement = document.querySelector( + '.org-top-card-summary__title', + ); + const domainNameElement = document.querySelector( + '.org-top-card-primary-actions__inner a', + ); + const addressElement = document.querySelectorAll( + '.org-top-card-summary-info-list__info-item', + )[1]; + const employeesNumberElement = document.querySelectorAll( + '.org-top-card-summary-info-list__info-item', + )[3]; - // Get the text content or other necessary data from the DOM elements - const companyName = companyNameElement - ? companyNameElement.getAttribute('title') - : ''; - const domainName = extractDomain( - domainNameElement && domainNameElement.getAttribute('href'), - ); - const address = addressElement - ? addressElement.textContent?.trim().replace(/\s+/g, ' ') - : ''; - const employees = employeesNumberElement - ? Number( - employeesNumberElement.textContent - ?.trim() - .replace(/\s+/g, ' ') - .split('-')[0], - ) - : 0; + // Get the text content or other necessary data from the DOM elements + const companyName = companyNameElement + ? companyNameElement.getAttribute('title') + : ''; + const domainName = extractDomain( + domainNameElement && domainNameElement.getAttribute('href'), + ); + const address = addressElement + ? addressElement.textContent?.trim().replace(/\s+/g, ' ') + : ''; + const employees = employeesNumberElement + ? Number( + employeesNumberElement.textContent + ?.trim() + .replace(/\s+/g, ' ') + .split('-')[0], + ) + : 0; - // Prepare company data to send to the backend - const companyData = { - name: companyName, - domainName: domainName, - address: address, - employees: employees, - linkedinLink: { url: '', label: '' }, - }; + // Prepare company data to send to the backend + const companyInputData: CompanyInput = { + name: companyName ?? '', + domainName: domainName, + address: address ?? '', + employees: employees, + }; - // Extract active tab url using chrome API - an event is triggered here and is caught by background script. - const { url: activeTabUrl } = await chrome.runtime.sendMessage({ - action: 'getActiveTabUrl', - }); + // Extract active tab url using chrome API - an event is triggered here and is caught by background script. + const { url: activeTabUrl } = await chrome.runtime.sendMessage({ + action: 'getActiveTabUrl', + }); - // Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty - const companyURL = extractCompanyLinkedinLink(activeTabUrl); - companyData.linkedinLink = { url: companyURL, label: companyURL }; + // Convert URLs like https://www.linkedin.com/company/twenty/about/ to https://www.linkedin.com/company/twenty + const companyURL = extractCompanyLinkedinLink(activeTabUrl); + companyInputData.linkedinLink = { url: companyURL, label: companyURL }; - const query = `mutation CreateOneCompany { createCompany(data:{${handleQueryParams( - companyData, - )}}) {id} }`; + const company = await fetchCompany({ + linkedinLink: { + url: { eq: companyURL }, + label: { eq: companyURL }, + }, + }); + if (company) { + const savedCompany: HTMLDivElement = createNewButton( + 'Saved', + async () => {}, + ); + // Include the button in the DOM. + parentDiv.prepend(savedCompany); - const response = await requestDb(query); + // Write button specific styles here - common ones can be found in createButton.ts. + const buttonSpecificStyles = { + alignSelf: 'end', + }; - if (response.data) { - newButtonCompany.textContent = 'Saved'; - newButtonCompany.setAttribute('disabled', 'true'); + Object.assign(savedCompany.style, buttonSpecificStyles); + } else { + const newButtonCompany: HTMLDivElement = createNewButton( + 'Add to Twenty', + async () => { + const response = await createCompany(companyInputData); - // Button specific styles once the button is unclickable after successfully sending data to server. - newButtonCompany.addEventListener('mouseenter', () => { - const hoverStyles = { - backgroundColor: 'black', - borderColor: 'black', - cursor: 'default', - }; - Object.assign(newButtonCompany.style, hoverStyles); - }); - } else { - newButtonCompany.textContent = 'Try Again'; - } - }, - ); + if (response) { + newButtonCompany.textContent = 'Saved'; + newButtonCompany.setAttribute('disabled', 'true'); - // Include the button in the DOM. - parentDiv.prepend(newButtonCompany); + // Button specific styles once the button is unclickable after successfully sending data to server. + newButtonCompany.addEventListener('mouseenter', () => { + const hoverStyles = { + backgroundColor: 'black', + borderColor: 'black', + cursor: 'default', + }; + Object.assign(newButtonCompany.style, hoverStyles); + }); + } else { + newButtonCompany.textContent = 'Try Again'; + } + }, + ); - // Write button specific styles here - common ones can be found in createButton.ts. - const buttonSpecificStyles = { - alignSelf: 'end', - }; + // Include the button in the DOM. + parentDiv.prepend(newButtonCompany); + + // Write button specific styles here - common ones can be found in createButton.ts. + const buttonSpecificStyles = { + alignSelf: 'end', + }; - Object.assign(newButtonCompany.style, buttonSpecificStyles); + Object.assign(newButtonCompany.style, buttonSpecificStyles); + } } }; diff --git a/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts b/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts index 4ddc1d06f3bb..2d5076dfa30a 100644 --- a/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts +++ b/packages/twenty-chrome-extension/src/contentScript/extractPersonProfile.ts @@ -1,9 +1,9 @@ import createNewButton from '~/contentScript/createButton'; import extractFirstAndLastName from '~/contentScript/utils/extractFirstAndLastName'; -import handleQueryParams from '~/utils/handleQueryParams'; -import requestDb from '~/utils/requestDb'; +import { createPerson, fetchPerson } from '~/db/person.db'; +import { PersonInput } from '~/db/types/person.types'; -const insertButtonForPerson = (): void => { +const insertButtonForPerson = async (): Promise => { // Select the element in which to create the button. const parentDiv: HTMLDivElement | null = document.querySelector( '.pv-top-card-v2-ctas', @@ -11,108 +11,116 @@ const insertButtonForPerson = (): void => { // Create the button with desired callback funciton to execute upon click. if (parentDiv) { - const newButtonPerson: HTMLButtonElement = createNewButton( - 'Add to Twenty', - async () => { - // Extract person-specific data from the DOM. - const personNameElement = document.querySelector( - '.text-heading-xlarge', - ); - - const separatorElement = document.querySelector( - '.pv-text-details__separator', - ); - const personCityElement = separatorElement?.previousElementSibling; - - const profilePictureElement = document.querySelector( - '.pv-top-card-profile-picture__image', - ); - - const firstListItem = document.querySelector( - 'div[data-view-name="profile-component-entity"]', - ); - const secondDivElement = - firstListItem?.querySelector('div:nth-child(2)'); - const ariaHiddenSpan = secondDivElement?.querySelector( - 'span[aria-hidden="true"]', - ); - - // Get the text content or other necessary data from the DOM elements. - const personName = personNameElement - ? personNameElement.textContent - : ''; - const personCity = personCityElement - ? personCityElement.textContent - ?.trim() - .replace(/\s+/g, ' ') - .split(',')[0] - : ''; - const profilePicture = profilePictureElement - ? profilePictureElement?.getAttribute('src') - : ''; - const jobTitle = ariaHiddenSpan - ? ariaHiddenSpan.textContent?.trim() - : ''; - - const { firstName, lastName } = extractFirstAndLastName( - String(personName), - ); - - // Prepare person data to send to the backend. - const personData = { - name: { firstName, lastName }, - city: personCity, - avatarUrl: profilePicture, - jobTitle, - linkedinLink: { url: '', label: '' }, - }; - - // Extract active tab url using chrome API - an event is triggered here and is caught by background script. - let { url: activeTabUrl } = await chrome.runtime.sendMessage({ - action: 'getActiveTabUrl', - }); - - // Remove last slash from the URL for consistency when saving usernames. - if (activeTabUrl.endsWith('/')) { - activeTabUrl = activeTabUrl.slice(0, -1); - } - - personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl }; - - const query = `mutation CreateOnePerson { createPerson(data:{${handleQueryParams( - personData, - )}}) {id} }`; - - const response = await requestDb(query); - - if (response.data) { - newButtonPerson.textContent = 'Saved'; - newButtonPerson.setAttribute('disabled', 'true'); - - // Button specific styles once the button is unclickable after successfully sending data to server. - newButtonPerson.addEventListener('mouseenter', () => { - const hoverStyles = { - backgroundColor: 'black', - borderColor: 'black', - cursor: 'default', - }; - Object.assign(newButtonPerson.style, hoverStyles); - }); - } else { - newButtonPerson.textContent = 'Try Again'; - } - }, + // Extract person-specific data from the DOM. + const personNameElement = document.querySelector('.text-heading-xlarge'); + + const separatorElement = document.querySelector( + '.pv-text-details__separator', ); + const personCityElement = separatorElement?.previousElementSibling; - // Include the button in the DOM. - parentDiv.prepend(newButtonPerson); + const profilePictureElement = document.querySelector( + '.pv-top-card-profile-picture__image', + ); + + const firstListItem = document.querySelector( + 'div[data-view-name="profile-component-entity"]', + ); + const secondDivElement = firstListItem?.querySelector('div:nth-child(2)'); + const ariaHiddenSpan = secondDivElement?.querySelector( + 'span[aria-hidden="true"]', + ); - // Write button specific styles here - common ones can be found in createButton.ts. - const buttonSpecificStyles = { - marginRight: '0.5em', + // Get the text content or other necessary data from the DOM elements. + const personName = personNameElement ? personNameElement.textContent : ''; + const personCity = personCityElement + ? personCityElement.textContent?.trim().replace(/\s+/g, ' ').split(',')[0] + : ''; + const profilePicture = profilePictureElement + ? profilePictureElement?.getAttribute('src') + : ''; + const jobTitle = ariaHiddenSpan ? ariaHiddenSpan.textContent?.trim() : ''; + + const { firstName, lastName } = extractFirstAndLastName(String(personName)); + + // Prepare person data to send to the backend. + const personData: PersonInput = { + name: { firstName, lastName }, + city: personCity ?? '', + avatarUrl: profilePicture ?? '', + jobTitle: jobTitle ?? '', + linkedinLink: { url: '', label: '' }, }; - Object.assign(newButtonPerson.style, buttonSpecificStyles); + // Extract active tab url using chrome API - an event is triggered here and is caught by background script. + let { url: activeTabUrl } = await chrome.runtime.sendMessage({ + action: 'getActiveTabUrl', + }); + + // Remove last slash from the URL for consistency when saving usernames. + if (activeTabUrl.endsWith('/')) { + activeTabUrl = activeTabUrl.slice(0, -1); + } + + personData.linkedinLink = { url: activeTabUrl, label: activeTabUrl }; + + const person = await fetchPerson({ + name: { + firstName: { eq: firstName }, + lastName: { eq: lastName }, + }, + linkedinLink: { url: { eq: activeTabUrl }, label: { eq: activeTabUrl } }, + }); + + if (person) { + const savedPerson: HTMLDivElement = createNewButton( + 'Saved', + async () => {}, + ); + + // Include the button in the DOM. + parentDiv.prepend(savedPerson); + + // Write button specific styles here - common ones can be found in createButton.ts. + const buttonSpecificStyles = { + marginRight: '0.5em', + }; + + Object.assign(savedPerson.style, buttonSpecificStyles); + } else { + const newButtonPerson: HTMLDivElement = createNewButton( + 'Add to Twenty', + async () => { + const response = await createPerson(personData); + if (response) { + newButtonPerson.textContent = 'Saved'; + newButtonPerson.setAttribute('disabled', 'true'); + + // Button specific styles once the button is unclickable after successfully sending data to server. + newButtonPerson.addEventListener('mouseenter', () => { + const hoverStyles = { + backgroundColor: 'black', + borderColor: 'black', + cursor: 'default', + }; + Object.assign(newButtonPerson.style, hoverStyles); + }); + } else { + newButtonPerson.textContent = 'Try Again'; + } + }, + ); + + // Include the button in the DOM. + parentDiv.prepend(newButtonPerson); + + // Write button specific styles here - common ones can be found in createButton.ts. + const buttonSpecificStyles = { + marginRight: '0.5em', + }; + + Object.assign(newButtonPerson.style, buttonSpecificStyles); + } } }; diff --git a/packages/twenty-chrome-extension/src/contentScript/index.ts b/packages/twenty-chrome-extension/src/contentScript/index.ts index a541e0144c39..2cf8d378e85b 100644 --- a/packages/twenty-chrome-extension/src/contentScript/index.ts +++ b/packages/twenty-chrome-extension/src/contentScript/index.ts @@ -3,18 +3,68 @@ import insertButtonForPerson from '~/contentScript/extractPersonProfile'; // Inject buttons into the DOM when SPA is reloaded on the resource url. // e.g. reload the page when on https://www.linkedin.com/in/mabdullahabaid/ -insertButtonForCompany(); -insertButtonForPerson(); +await insertButtonForCompany(); +await insertButtonForPerson(); // The content script gets executed upon load, so the the content script is executed when a user visits https://www.linkedin.com/feed/. // However, there would never be another reload in a single page application unless triggered manually. // Therefore, if the user navigates to a person or a company page, we must manually re-execute the content script to create the "Add to Twenty" button. // e.g. create "Add to Twenty" button when a user navigates to https://www.linkedin.com/in/mabdullahabaid/ from https://www.linkedin.com/feed/ -chrome.runtime.onMessage.addListener((message, _, sendResponse) => { +chrome.runtime.onMessage.addListener(async (message, _, sendResponse) => { if (message.action === 'executeContentScript') { - insertButtonForCompany(); - insertButtonForPerson(); + await insertButtonForCompany(); + await insertButtonForPerson(); + } + + if (message.action === 'TOGGLE') { + toggle(); } sendResponse('Executing!'); }); + +const createIframe = () => { + const iframe = document.createElement('iframe'); + iframe.style.background = 'lightgrey'; + iframe.style.height = '100vh'; + iframe.style.width = '400px'; + iframe.style.position = 'fixed'; + iframe.style.top = '0px'; + iframe.style.right = '-400px'; + iframe.style.zIndex = '9000000000000000000'; + iframe.style.transition = 'ease-in-out 0.3s'; + return iframe; +}; + +const handleContentIframeLoadComplete = () => { + //If the pop-out window is already open then we replace loading iframe with our content iframe + if (loadingIframe.style.right === '0px') contentIframe.style.right = '0px'; + loadingIframe.style.display = 'none'; + contentIframe.style.display = 'block'; +}; + +//Creating one iframe where we are loading our front end in the background +const contentIframe = createIframe(); +contentIframe.style.display = 'none'; +contentIframe.src = `${import.meta.env.VITE_FRONT_BASE_URL}`; +contentIframe.onload = handleContentIframeLoadComplete; + +//Creating this iframe to show as a loading state until the above iframe loads completely +const loadingIframe = createIframe(); +loadingIframe.src = chrome.runtime.getURL('loading.html'); + +document.body.appendChild(loadingIframe); +document.body.appendChild(contentIframe); + +const toggleIframe = (iframe: HTMLIFrameElement) => { + if (iframe.style.right === '-400px' && iframe.style.display !== 'none') { + iframe.style.right = '0px'; + } else if (iframe.style.right === '0px' && iframe.style.display !== 'none') { + iframe.style.right = '-400px'; + } +}; + +const toggle = () => { + toggleIframe(loadingIframe); + toggleIframe(contentIframe); +}; diff --git a/packages/twenty-chrome-extension/src/db/company.db.ts b/packages/twenty-chrome-extension/src/db/company.db.ts new file mode 100644 index 000000000000..f0936e1b4ef9 --- /dev/null +++ b/packages/twenty-chrome-extension/src/db/company.db.ts @@ -0,0 +1,38 @@ +import { + CompanyInput, + CreateCompanyResponse, + FindCompanyResponse, +} from '~/db/types/company.types'; +import { Company, CompanyFilterInput } from '~/generated/graphql'; +import { CREATE_COMPANY } from '~/graphql/company/mutations'; +import { FIND_COMPANY } from '~/graphql/company/queries'; + +import { callMutation, callQuery } from '../utils/requestDb'; + +export const fetchCompany = async ( + companyfilerInput: CompanyFilterInput, +): Promise => { + const data = await callQuery(FIND_COMPANY, { + filter: { + ...companyfilerInput, + }, + }); + if (data?.companies.edges) { + return data?.companies.edges.length > 0 + ? data?.companies.edges[0].node + : null; + } + return null; +}; + +export const createCompany = async ( + company: CompanyInput, +): Promise => { + const data = await callMutation(CREATE_COMPANY, { + input: company, + }); + if (data) { + return data.createCompany.id; + } + return null; +}; diff --git a/packages/twenty-chrome-extension/src/db/person.db.ts b/packages/twenty-chrome-extension/src/db/person.db.ts new file mode 100644 index 000000000000..29c782b3879e --- /dev/null +++ b/packages/twenty-chrome-extension/src/db/person.db.ts @@ -0,0 +1,36 @@ +import { + CreatePersonResponse, + FindPersonResponse, + PersonInput, +} from '~/db/types/person.types'; +import { Person, PersonFilterInput } from '~/generated/graphql'; +import { CREATE_PERSON } from '~/graphql/person/mutations'; +import { FIND_PERSON } from '~/graphql/person/queries'; + +import { callMutation, callQuery } from '../utils/requestDb'; + +export const fetchPerson = async ( + personFilterData: PersonFilterInput, +): Promise => { + const data = await callQuery(FIND_PERSON, { + filter: { + ...personFilterData, + }, + }); + if (data?.people.edges) { + return data?.people.edges.length > 0 ? data?.people.edges[0].node : null; + } + return null; +}; + +export const createPerson = async ( + person: PersonInput, +): Promise => { + const data = await callMutation(CREATE_PERSON, { + input: person, + }); + if (data?.createPerson) { + return data.createPerson.id; + } + return null; +}; diff --git a/packages/twenty-chrome-extension/src/db/types/company.types.ts b/packages/twenty-chrome-extension/src/db/types/company.types.ts new file mode 100644 index 000000000000..f6c1241652fb --- /dev/null +++ b/packages/twenty-chrome-extension/src/db/types/company.types.ts @@ -0,0 +1,10 @@ +import { Company, CompanyConnection } from '~/generated/graphql'; + +export type CompanyInput = Pick< + Company, + 'name' | 'domainName' | 'address' | 'employees' | 'linkedinLink' +>; +export type FindCompanyResponse = { + companies: Pick; +}; +export type CreateCompanyResponse = { createCompany: { id: string } }; diff --git a/packages/twenty-chrome-extension/src/db/types/person.types.ts b/packages/twenty-chrome-extension/src/db/types/person.types.ts new file mode 100644 index 000000000000..914240eb144e --- /dev/null +++ b/packages/twenty-chrome-extension/src/db/types/person.types.ts @@ -0,0 +1,8 @@ +import { Person, PersonConnection } from '~/generated/graphql'; + +export type PersonInput = Pick< + Person, + 'name' | 'city' | 'avatarUrl' | 'jobTitle' | 'linkedinLink' +>; +export type FindPersonResponse = { people: Pick }; +export type CreatePersonResponse = { createPerson: { id: string } }; diff --git a/packages/twenty-chrome-extension/src/generated/graphql.tsx b/packages/twenty-chrome-extension/src/generated/graphql.tsx new file mode 100644 index 000000000000..f48e6ba5dd4b --- /dev/null +++ b/packages/twenty-chrome-extension/src/generated/graphql.tsx @@ -0,0 +1,5632 @@ +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] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +const defaultOptions = {} as const; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: string; + String: string; + Boolean: boolean; + Int: number; + Float: number; + BigFloat: any; + ConnectionCursor: any; + Cursor: any; + Date: any; + DateTime: any; + JSON: any; + UUID: any; + Upload: any; +}; + +export type ActivateWorkspaceInput = { + displayName?: InputMaybe; +}; + +/** An activity */ +export type Activity = { + /** Activity targets */ + activityTargets?: Maybe; + /** Acitivity assignee */ + assignee?: Maybe; + /** Acitivity assignee id foreign key */ + assigneeId?: Maybe; + /** Activity attachments */ + attachments?: Maybe; + /** Activity author */ + author?: Maybe; + /** Activity author id foreign key */ + authorId: Scalars['ID']; + /** Activity body */ + body: Scalars['String']; + /** Activity comments */ + comments?: Maybe; + /** Activity completion date */ + completedAt?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Activity due date */ + dueAt?: Maybe; + id: Scalars['ID']; + /** Activity reminder date */ + reminderAt?: Maybe; + /** Activity title */ + title: Scalars['String']; + /** Activity type */ + type: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + + +/** An activity */ +export type ActivityActivityTargetsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** An activity */ +export type ActivityAttachmentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** An activity */ +export type ActivityCommentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** An activity */ +export type ActivityConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** An activity */ +export type ActivityCreateInput = { + /** Acitivity assignee id foreign key */ + assigneeId?: InputMaybe; + /** Activity author id foreign key */ + authorId: Scalars['ID']; + /** Activity body */ + body?: InputMaybe; + /** Activity completion date */ + completedAt?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Activity due date */ + dueAt?: InputMaybe; + id?: InputMaybe; + /** Activity reminder date */ + reminderAt?: InputMaybe; + /** Activity title */ + title?: InputMaybe; + /** Activity type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity */ +export type ActivityEdge = { + cursor: Scalars['Cursor']; + node: Activity; +}; + +/** An activity */ +export type ActivityFilterInput = { + and?: InputMaybe>>; + /** Acitivity assignee id foreign key */ + assigneeId?: InputMaybe; + /** Activity author id foreign key */ + authorId?: InputMaybe; + /** Activity body */ + body?: InputMaybe; + /** Activity completion date */ + completedAt?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Activity due date */ + dueAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Activity reminder date */ + reminderAt?: InputMaybe; + /** Activity title */ + title?: InputMaybe; + /** Activity type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity */ +export type ActivityOrderByInput = { + /** Acitivity assignee id foreign key */ + assigneeId?: InputMaybe; + /** Activity author id foreign key */ + authorId?: InputMaybe; + /** Activity body */ + body?: InputMaybe; + /** Activity completion date */ + completedAt?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Activity due date */ + dueAt?: InputMaybe; + id?: InputMaybe; + /** Activity reminder date */ + reminderAt?: InputMaybe; + /** Activity title */ + title?: InputMaybe; + /** Activity type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity target */ +export type ActivityTarget = { + /** ActivityTarget activity */ + activity?: Maybe; + /** ActivityTarget activity id foreign key */ + activityId?: Maybe; + /** ActivityTarget company */ + company?: Maybe; + /** ActivityTarget company id foreign key */ + companyId?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** ActivityTarget opportunity */ + opportunity?: Maybe; + /** ActivityTarget opportunity id foreign key */ + opportunityId?: Maybe; + /** ActivityTarget person */ + person?: Maybe; + /** ActivityTarget person id foreign key */ + personId?: Maybe; + updatedAt: Scalars['DateTime']; +}; + +/** An activity target */ +export type ActivityTargetConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** An activity target */ +export type ActivityTargetCreateInput = { + /** ActivityTarget activity id foreign key */ + activityId?: InputMaybe; + /** ActivityTarget company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** ActivityTarget opportunity id foreign key */ + opportunityId?: InputMaybe; + /** ActivityTarget person id foreign key */ + personId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity target */ +export type ActivityTargetEdge = { + cursor: Scalars['Cursor']; + node: ActivityTarget; +}; + +/** An activity target */ +export type ActivityTargetFilterInput = { + /** ActivityTarget activity id foreign key */ + activityId?: InputMaybe; + and?: InputMaybe>>; + /** ActivityTarget company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + /** ActivityTarget opportunity id foreign key */ + opportunityId?: InputMaybe; + or?: InputMaybe>>; + /** ActivityTarget person id foreign key */ + personId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity target */ +export type ActivityTargetOrderByInput = { + /** ActivityTarget activity id foreign key */ + activityId?: InputMaybe; + /** ActivityTarget company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** ActivityTarget opportunity id foreign key */ + opportunityId?: InputMaybe; + /** ActivityTarget person id foreign key */ + personId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity target */ +export type ActivityTargetUpdateInput = { + /** ActivityTarget activity id foreign key */ + activityId?: InputMaybe; + /** ActivityTarget company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** ActivityTarget opportunity id foreign key */ + opportunityId?: InputMaybe; + /** ActivityTarget person id foreign key */ + personId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An activity */ +export type ActivityUpdateInput = { + /** Acitivity assignee id foreign key */ + assigneeId?: InputMaybe; + /** Activity author id foreign key */ + authorId?: InputMaybe; + /** Activity body */ + body?: InputMaybe; + /** Activity completion date */ + completedAt?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Activity due date */ + dueAt?: InputMaybe; + id?: InputMaybe; + /** Activity reminder date */ + reminderAt?: InputMaybe; + /** Activity title */ + title?: InputMaybe; + /** Activity type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type Analytics = { + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + +/** An api key */ +export type ApiKey = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** ApiKey expiration date */ + expiresAt: Scalars['DateTime']; + id: Scalars['ID']; + /** ApiKey name */ + name: Scalars['String']; + /** ApiKey revocation date */ + revokedAt?: Maybe; + updatedAt: Scalars['DateTime']; +}; + +/** An api key */ +export type ApiKeyConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** An api key */ +export type ApiKeyCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** ApiKey expiration date */ + expiresAt: Scalars['DateTime']; + id?: InputMaybe; + /** ApiKey name */ + name?: InputMaybe; + /** ApiKey revocation date */ + revokedAt?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An api key */ +export type ApiKeyEdge = { + cursor: Scalars['Cursor']; + node: ApiKey; +}; + +/** An api key */ +export type ApiKeyFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** ApiKey expiration date */ + expiresAt?: InputMaybe; + id?: InputMaybe; + /** ApiKey name */ + name?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** ApiKey revocation date */ + revokedAt?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An api key */ +export type ApiKeyOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** ApiKey expiration date */ + expiresAt?: InputMaybe; + id?: InputMaybe; + /** ApiKey name */ + name?: InputMaybe; + /** ApiKey revocation date */ + revokedAt?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type ApiKeyToken = { + token: Scalars['String']; +}; + +/** An api key */ +export type ApiKeyUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** ApiKey expiration date */ + expiresAt?: InputMaybe; + id?: InputMaybe; + /** ApiKey name */ + name?: InputMaybe; + /** ApiKey revocation date */ + revokedAt?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An attachment */ +export type Attachment = { + /** Attachment activity */ + activity?: Maybe; + /** Attachment activity id foreign key */ + activityId?: Maybe; + /** Attachment author */ + author?: Maybe; + /** Attachment author id foreign key */ + authorId: Scalars['ID']; + /** Attachment company */ + company?: Maybe; + /** Attachment company id foreign key */ + companyId?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Attachment full path */ + fullPath: Scalars['String']; + id: Scalars['ID']; + /** Attachment name */ + name: Scalars['String']; + /** Attachment opportunity */ + opportunity?: Maybe; + /** Attachment opportunity id foreign key */ + opportunityId?: Maybe; + /** Attachment person */ + person?: Maybe; + /** Attachment person id foreign key */ + personId?: Maybe; + /** Attachment type */ + type: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +/** An attachment */ +export type AttachmentConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** An attachment */ +export type AttachmentCreateInput = { + /** Attachment activity id foreign key */ + activityId?: InputMaybe; + /** Attachment author id foreign key */ + authorId: Scalars['ID']; + /** Attachment company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Attachment full path */ + fullPath?: InputMaybe; + id?: InputMaybe; + /** Attachment name */ + name?: InputMaybe; + /** Attachment opportunity id foreign key */ + opportunityId?: InputMaybe; + /** Attachment person id foreign key */ + personId?: InputMaybe; + /** Attachment type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An attachment */ +export type AttachmentEdge = { + cursor: Scalars['Cursor']; + node: Attachment; +}; + +/** An attachment */ +export type AttachmentFilterInput = { + /** Attachment activity id foreign key */ + activityId?: InputMaybe; + and?: InputMaybe>>; + /** Attachment author id foreign key */ + authorId?: InputMaybe; + /** Attachment company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Attachment full path */ + fullPath?: InputMaybe; + id?: InputMaybe; + /** Attachment name */ + name?: InputMaybe; + not?: InputMaybe; + /** Attachment opportunity id foreign key */ + opportunityId?: InputMaybe; + or?: InputMaybe>>; + /** Attachment person id foreign key */ + personId?: InputMaybe; + /** Attachment type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An attachment */ +export type AttachmentOrderByInput = { + /** Attachment activity id foreign key */ + activityId?: InputMaybe; + /** Attachment author id foreign key */ + authorId?: InputMaybe; + /** Attachment company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Attachment full path */ + fullPath?: InputMaybe; + id?: InputMaybe; + /** Attachment name */ + name?: InputMaybe; + /** Attachment opportunity id foreign key */ + opportunityId?: InputMaybe; + /** Attachment person id foreign key */ + personId?: InputMaybe; + /** Attachment type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An attachment */ +export type AttachmentUpdateInput = { + /** Attachment activity id foreign key */ + activityId?: InputMaybe; + /** Attachment author id foreign key */ + authorId?: InputMaybe; + /** Attachment company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Attachment full path */ + fullPath?: InputMaybe; + id?: InputMaybe; + /** Attachment name */ + name?: InputMaybe; + /** Attachment opportunity id foreign key */ + opportunityId?: InputMaybe; + /** Attachment person id foreign key */ + personId?: InputMaybe; + /** Attachment type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type AuthProviders = { + google: Scalars['Boolean']; + magicLink: Scalars['Boolean']; + password: Scalars['Boolean']; +}; + +export type AuthToken = { + expiresAt: Scalars['DateTime']; + token: Scalars['String']; +}; + +export type AuthTokenPair = { + accessToken: AuthToken; + refreshToken: AuthToken; +}; + +export type AuthTokens = { + tokens: AuthTokenPair; +}; + +export type BigFloatFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; + is?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; +}; + +export type Billing = { + billingFreeTrialDurationInDays?: Maybe; + billingUrl: Scalars['String']; + isBillingEnabled: Scalars['Boolean']; +}; + +/** Blocklist */ +export type Blocklist = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Handle */ + handle: Scalars['String']; + id: Scalars['ID']; + updatedAt: Scalars['DateTime']; + /** WorkspaceMember */ + workspaceMember?: Maybe; + /** WorkspaceMember id foreign key */ + workspaceMemberId: Scalars['ID']; +}; + +/** Blocklist */ +export type BlocklistConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** Blocklist */ +export type BlocklistCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; + /** WorkspaceMember id foreign key */ + workspaceMemberId: Scalars['ID']; +}; + +/** Blocklist */ +export type BlocklistEdge = { + cursor: Scalars['Cursor']; + node: Blocklist; +}; + +/** Blocklist */ +export type BlocklistFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; + /** WorkspaceMember id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** Blocklist */ +export type BlocklistOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; + /** WorkspaceMember id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** Blocklist */ +export type BlocklistUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; + /** WorkspaceMember id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +export type BooleanFieldComparison = { + is?: InputMaybe; + isNot?: InputMaybe; +}; + +export type BooleanFilter = { + eq?: InputMaybe; + is?: InputMaybe; +}; + +export type CheckoutEntity = { + url: Scalars['String']; +}; + +export type ClientConfig = { + authProviders: AuthProviders; + billing: Billing; + debugMode: Scalars['Boolean']; + sentry: Sentry; + signInPrefilled: Scalars['Boolean']; + signUpDisabled: Scalars['Boolean']; + support: Support; + telemetry: Telemetry; +}; + +/** A comment */ +export type Comment = { + /** Comment activity */ + activity?: Maybe; + /** Comment activity id foreign key */ + activityId: Scalars['ID']; + /** Comment author */ + author?: Maybe; + /** Comment author id foreign key */ + authorId: Scalars['ID']; + /** Comment body */ + body: Scalars['String']; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + updatedAt: Scalars['DateTime']; +}; + +/** A comment */ +export type CommentConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A comment */ +export type CommentCreateInput = { + /** Comment activity id foreign key */ + activityId: Scalars['ID']; + /** Comment author id foreign key */ + authorId: Scalars['ID']; + /** Comment body */ + body?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A comment */ +export type CommentEdge = { + cursor: Scalars['Cursor']; + node: Comment; +}; + +/** A comment */ +export type CommentFilterInput = { + /** Comment activity id foreign key */ + activityId?: InputMaybe; + and?: InputMaybe>>; + /** Comment author id foreign key */ + authorId?: InputMaybe; + /** Comment body */ + body?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; +}; + +/** A comment */ +export type CommentOrderByInput = { + /** Comment activity id foreign key */ + activityId?: InputMaybe; + /** Comment author id foreign key */ + authorId?: InputMaybe; + /** Comment body */ + body?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A comment */ +export type CommentUpdateInput = { + /** Comment activity id foreign key */ + activityId?: InputMaybe; + /** Comment author id foreign key */ + authorId?: InputMaybe; + /** Comment body */ + body?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A company */ +export type Company = { + /** Your team member responsible for managing the company account */ + accountOwner?: Maybe; + /** Your team member responsible for managing the company account id foreign key */ + accountOwnerId?: Maybe; + /** Activities tied to the company */ + activityTargets?: Maybe; + /** The company address */ + address: Scalars['String']; + /** Annual Recurring Revenue: The actual or estimated annual revenue of the company */ + annualRecurringRevenue?: Maybe; + /** Attachments linked to the company. */ + attachments?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** The company website URL. We use this url to fetch the company icon */ + domainName: Scalars['String']; + /** Number of employees in the company */ + employees?: Maybe; + /** Favorites linked to the company */ + favorites?: Maybe; + id: Scalars['ID']; + /** Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you */ + idealCustomerProfile: Scalars['Boolean']; + /** The company Linkedin account */ + linkedinLink?: Maybe; + /** The company name */ + name: Scalars['String']; + /** Opportunities linked to the company. */ + opportunities?: Maybe; + /** People linked to the company. */ + people?: Maybe; + /** Position */ + position?: Maybe; + updatedAt: Scalars['DateTime']; + /** The company Twitter/X account */ + xLink?: Maybe; +}; + + +/** A company */ +export type CompanyActivityTargetsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A company */ +export type CompanyAttachmentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A company */ +export type CompanyFavoritesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A company */ +export type CompanyOpportunitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A company */ +export type CompanyPeopleArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** A company */ +export type CompanyConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A company */ +export type CompanyCreateInput = { + /** Your team member responsible for managing the company account id foreign key */ + accountOwnerId?: InputMaybe; + /** The company address */ + address?: InputMaybe; + /** Annual Recurring Revenue: The actual or estimated annual revenue of the company */ + annualRecurringRevenue?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The company website URL. We use this url to fetch the company icon */ + domainName?: InputMaybe; + /** Number of employees in the company */ + employees?: InputMaybe; + id?: InputMaybe; + /** Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you */ + idealCustomerProfile?: InputMaybe; + /** The company Linkedin account */ + linkedinLink?: InputMaybe; + /** The company name */ + name?: InputMaybe; + /** Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** The company Twitter/X account */ + xLink?: InputMaybe; +}; + +/** A company */ +export type CompanyEdge = { + cursor: Scalars['Cursor']; + node: Company; +}; + +/** A company */ +export type CompanyFilterInput = { + /** Your team member responsible for managing the company account id foreign key */ + accountOwnerId?: InputMaybe; + /** The company address */ + address?: InputMaybe; + and?: InputMaybe>>; + /** Annual Recurring Revenue: The actual or estimated annual revenue of the company */ + annualRecurringRevenue?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The company website URL. We use this url to fetch the company icon */ + domainName?: InputMaybe; + /** Number of employees in the company */ + employees?: InputMaybe; + id?: InputMaybe; + /** Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you */ + idealCustomerProfile?: InputMaybe; + /** The company Linkedin account */ + linkedinLink?: InputMaybe; + /** The company name */ + name?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** The company Twitter/X account */ + xLink?: InputMaybe; +}; + +/** A company */ +export type CompanyOrderByInput = { + /** Your team member responsible for managing the company account id foreign key */ + accountOwnerId?: InputMaybe; + /** The company address */ + address?: InputMaybe; + /** Annual Recurring Revenue: The actual or estimated annual revenue of the company */ + annualRecurringRevenue?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The company website URL. We use this url to fetch the company icon */ + domainName?: InputMaybe; + /** Number of employees in the company */ + employees?: InputMaybe; + id?: InputMaybe; + /** Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you */ + idealCustomerProfile?: InputMaybe; + /** The company Linkedin account */ + linkedinLink?: InputMaybe; + /** The company name */ + name?: InputMaybe; + /** Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** The company Twitter/X account */ + xLink?: InputMaybe; +}; + +/** A company */ +export type CompanyUpdateInput = { + /** Your team member responsible for managing the company account id foreign key */ + accountOwnerId?: InputMaybe; + /** The company address */ + address?: InputMaybe; + /** Annual Recurring Revenue: The actual or estimated annual revenue of the company */ + annualRecurringRevenue?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The company website URL. We use this url to fetch the company icon */ + domainName?: InputMaybe; + /** Number of employees in the company */ + employees?: InputMaybe; + id?: InputMaybe; + /** Ideal Customer Profile: Indicates whether the company is the most suitable and valuable customer for you */ + idealCustomerProfile?: InputMaybe; + /** The company Linkedin account */ + linkedinLink?: InputMaybe; + /** The company name */ + name?: InputMaybe; + /** Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** The company Twitter/X account */ + xLink?: InputMaybe; +}; + +/** A connected account */ +export type ConnectedAccount = { + /** Messaging provider access token */ + accessToken: Scalars['String']; + /** Account Owner */ + accountOwner?: Maybe; + /** Account Owner id foreign key */ + accountOwnerId: Scalars['ID']; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** The account handle (email, username, phone number, etc.) */ + handle: Scalars['String']; + id: Scalars['ID']; + /** Last sync history ID */ + lastSyncHistoryId: Scalars['String']; + /** Message Channel */ + messageChannels?: Maybe; + /** The account provider */ + provider: Scalars['String']; + /** Messaging provider refresh token */ + refreshToken: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + + +/** A connected account */ +export type ConnectedAccountMessageChannelsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** A connected account */ +export type ConnectedAccountConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A connected account */ +export type ConnectedAccountCreateInput = { + /** Messaging provider access token */ + accessToken?: InputMaybe; + /** Account Owner id foreign key */ + accountOwnerId: Scalars['ID']; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The account handle (email, username, phone number, etc.) */ + handle?: InputMaybe; + id?: InputMaybe; + /** Last sync history ID */ + lastSyncHistoryId?: InputMaybe; + /** The account provider */ + provider?: InputMaybe; + /** Messaging provider refresh token */ + refreshToken?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A connected account */ +export type ConnectedAccountEdge = { + cursor: Scalars['Cursor']; + node: ConnectedAccount; +}; + +/** A connected account */ +export type ConnectedAccountFilterInput = { + /** Messaging provider access token */ + accessToken?: InputMaybe; + /** Account Owner id foreign key */ + accountOwnerId?: InputMaybe; + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The account handle (email, username, phone number, etc.) */ + handle?: InputMaybe; + id?: InputMaybe; + /** Last sync history ID */ + lastSyncHistoryId?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** The account provider */ + provider?: InputMaybe; + /** Messaging provider refresh token */ + refreshToken?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A connected account */ +export type ConnectedAccountOrderByInput = { + /** Messaging provider access token */ + accessToken?: InputMaybe; + /** Account Owner id foreign key */ + accountOwnerId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The account handle (email, username, phone number, etc.) */ + handle?: InputMaybe; + id?: InputMaybe; + /** Last sync history ID */ + lastSyncHistoryId?: InputMaybe; + /** The account provider */ + provider?: InputMaybe; + /** Messaging provider refresh token */ + refreshToken?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A connected account */ +export type ConnectedAccountUpdateInput = { + /** Messaging provider access token */ + accessToken?: InputMaybe; + /** Account Owner id foreign key */ + accountOwnerId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** The account handle (email, username, phone number, etc.) */ + handle?: InputMaybe; + id?: InputMaybe; + /** Last sync history ID */ + lastSyncHistoryId?: InputMaybe; + /** The account provider */ + provider?: InputMaybe; + /** Messaging provider refresh token */ + refreshToken?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type Currency = { + amountMicros?: Maybe; + createdAt?: Maybe; + currencyCode?: Maybe; + deletedAt?: Maybe; + id?: Maybe; + updatedAt?: Maybe; +}; + +export type CurrencyCreateInput = { + amountMicros?: InputMaybe; + createdAt?: InputMaybe; + currencyCode?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type CurrencyFilterInput = { + amountMicros?: InputMaybe; + and?: InputMaybe>>; + createdAt?: InputMaybe; + currencyCode?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; +}; + +export type CurrencyOrderByInput = { + amountMicros?: InputMaybe; + createdAt?: InputMaybe; + currencyCode?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type CurrencyUpdateInput = { + amountMicros?: InputMaybe; + createdAt?: InputMaybe; + currencyCode?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type CursorPaging = { + /** Paginate after opaque cursor */ + after?: InputMaybe; + /** Paginate before opaque cursor */ + before?: InputMaybe; + /** Paginate first */ + first?: InputMaybe; + /** Paginate last */ + last?: InputMaybe; +}; + +export type DateFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; + is?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; +}; + +export type DeleteOneObjectInput = { + /** The id of the record to delete. */ + id: Scalars['ID']; +}; + +export type EmailPasswordResetLink = { + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + +/** A favorite */ +export type Favorite = { + /** Favorite company */ + company?: Maybe; + /** Favorite company id foreign key */ + companyId?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** Favorite opportunity */ + opportunity?: Maybe; + /** Favorite opportunity id foreign key */ + opportunityId?: Maybe; + /** Favorite person */ + person?: Maybe; + /** Favorite person id foreign key */ + personId?: Maybe; + /** Favorite position */ + position: Scalars['Float']; + updatedAt: Scalars['DateTime']; + /** Favorite workspace member */ + workspaceMember?: Maybe; + /** Favorite workspace member id foreign key */ + workspaceMemberId: Scalars['ID']; +}; + +/** A favorite */ +export type FavoriteConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A favorite */ +export type FavoriteCreateInput = { + /** Favorite company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Favorite opportunity id foreign key */ + opportunityId?: InputMaybe; + /** Favorite person id foreign key */ + personId?: InputMaybe; + /** Favorite position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Favorite workspace member id foreign key */ + workspaceMemberId: Scalars['ID']; +}; + +/** A favorite */ +export type FavoriteEdge = { + cursor: Scalars['Cursor']; + node: Favorite; +}; + +/** A favorite */ +export type FavoriteFilterInput = { + and?: InputMaybe>>; + /** Favorite company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + /** Favorite opportunity id foreign key */ + opportunityId?: InputMaybe; + or?: InputMaybe>>; + /** Favorite person id foreign key */ + personId?: InputMaybe; + /** Favorite position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Favorite workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** A favorite */ +export type FavoriteOrderByInput = { + /** Favorite company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Favorite opportunity id foreign key */ + opportunityId?: InputMaybe; + /** Favorite person id foreign key */ + personId?: InputMaybe; + /** Favorite position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Favorite workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** A favorite */ +export type FavoriteUpdateInput = { + /** Favorite company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Favorite opportunity id foreign key */ + opportunityId?: InputMaybe; + /** Favorite person id foreign key */ + personId?: InputMaybe; + /** Favorite position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Favorite workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +export type FeatureFlag = { + id: Scalars['ID']; + key: Scalars['String']; + value: Scalars['Boolean']; + workspaceId: Scalars['String']; +}; + +export type FeatureFlagFilter = { + and?: InputMaybe>; + id?: InputMaybe; + or?: InputMaybe>; +}; + +export type FeatureFlagSort = { + direction: SortDirection; + field: FeatureFlagSortFields; + nulls?: InputMaybe; +}; + +export enum FeatureFlagSortFields { + Id = 'id' +} + +export type FieldConnection = { + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; + /** Fetch total count of records */ + totalCount: Scalars['Int']; +}; + +export type FieldDeleteResponse = { + createdAt?: Maybe; + defaultValue?: Maybe; + description?: Maybe; + icon?: Maybe; + id?: Maybe; + isActive?: Maybe; + isCustom?: Maybe; + isNullable?: Maybe; + isSystem?: Maybe; + label?: Maybe; + name?: Maybe; + options?: Maybe; + type?: Maybe; + updatedAt?: Maybe; +}; + +/** Type of the field */ +export enum FieldMetadataType { + Boolean = 'BOOLEAN', + Currency = 'CURRENCY', + Date = 'DATE', + DateTime = 'DATE_TIME', + Email = 'EMAIL', + FullName = 'FULL_NAME', + Link = 'LINK', + MultiSelect = 'MULTI_SELECT', + Number = 'NUMBER', + Numeric = 'NUMERIC', + Phone = 'PHONE', + Probability = 'PROBABILITY', + Rating = 'RATING', + Relation = 'RELATION', + Select = 'SELECT', + Text = 'TEXT', + Uuid = 'UUID' +} + +export enum FileFolder { + Attachment = 'Attachment', + PersonPicture = 'PersonPicture', + ProfilePicture = 'ProfilePicture', + WorkspaceLogo = 'WorkspaceLogo' +} + +/** This enum to filter by nullability */ +export enum FilterIs { + /** Non-nulish values */ + NotNull = 'NOT_NULL', + /** Nulish values */ + Null = 'NULL' +} + +export type FloatFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + in?: InputMaybe>; + is?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; +}; + +export type FullName = { + createdAt?: Maybe; + deletedAt?: Maybe; + firstName: Scalars['String']; + id?: Maybe; + lastName: Scalars['String']; + updatedAt?: Maybe; +}; + +export type FullNameCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + firstName?: InputMaybe; + id?: InputMaybe; + lastName?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type FullNameFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + firstName?: InputMaybe; + id?: InputMaybe; + lastName?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; +}; + +export type FullNameOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + firstName?: InputMaybe; + id?: InputMaybe; + lastName?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type FullNameUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + firstName?: InputMaybe; + id?: InputMaybe; + lastName?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type IdFilterComparison = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + iLike?: InputMaybe; + in?: InputMaybe>; + is?: InputMaybe; + isNot?: InputMaybe; + like?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; + notILike?: InputMaybe; + notIn?: InputMaybe>; + notLike?: InputMaybe; +}; + +export type InvalidatePassword = { + /** Boolean that confirms query was dispatched */ + success: Scalars['Boolean']; +}; + +export type Link = { + createdAt?: Maybe; + deletedAt?: Maybe; + id?: Maybe; + label?: Maybe; + updatedAt?: Maybe; + url?: Maybe; +}; + +export type LinkCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + label?: InputMaybe; + updatedAt?: InputMaybe; + url?: InputMaybe; +}; + +export type LinkFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + label?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; + url?: InputMaybe; +}; + +export type LinkOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + label?: InputMaybe; + updatedAt?: InputMaybe; + url?: InputMaybe; +}; + +export type LinkUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + label?: InputMaybe; + updatedAt?: InputMaybe; + url?: InputMaybe; +}; + +export type LoginToken = { + loginToken: AuthToken; +}; + +/** Message */ +export type Message = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Message Direction */ + direction: MessageDirectionEnum; + /** Message id from the message header */ + headerMessageId: Scalars['String']; + id: Scalars['ID']; + /** Messages from the channel. */ + messageChannelMessageAssociations?: Maybe; + /** Message Participants */ + messageParticipants?: Maybe; + /** Message Thread Id */ + messageThread?: Maybe; + /** Message Thread Id id foreign key */ + messageThreadId?: Maybe; + /** The date the message was received */ + receivedAt?: Maybe; + /** Subject */ + subject: Scalars['String']; + /** Text */ + text: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + + +/** Message */ +export type MessageMessageChannelMessageAssociationsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** Message */ +export type MessageMessageParticipantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** Message Channels */ +export type MessageChannel = { + /** Connected Account */ + connectedAccount?: Maybe; + /** Connected Account id foreign key */ + connectedAccountId: Scalars['ID']; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Handle */ + handle: Scalars['String']; + id: Scalars['ID']; + /** Is Contact Auto Creation Enabled */ + isContactAutoCreationEnabled: Scalars['Boolean']; + /** Messages from the channel. */ + messageChannelMessageAssociations?: Maybe; + /** Channel Type */ + type: MessageChannelTypeEnum; + updatedAt: Scalars['DateTime']; + /** Visibility */ + visibility: MessageChannelVisibilityEnum; +}; + + +/** Message Channels */ +export type MessageChannelMessageChannelMessageAssociationsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** Message Channels */ +export type MessageChannelConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** Message Channels */ +export type MessageChannelCreateInput = { + /** Connected Account id foreign key */ + connectedAccountId: Scalars['ID']; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Is Contact Auto Creation Enabled */ + isContactAutoCreationEnabled?: InputMaybe; + /** Channel Type */ + type?: InputMaybe; + updatedAt?: InputMaybe; + /** Visibility */ + visibility?: InputMaybe; +}; + +/** Message Channels */ +export type MessageChannelEdge = { + cursor: Scalars['Cursor']; + node: MessageChannel; +}; + +/** Message Channels */ +export type MessageChannelFilterInput = { + and?: InputMaybe>>; + /** Connected Account id foreign key */ + connectedAccountId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Is Contact Auto Creation Enabled */ + isContactAutoCreationEnabled?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Channel Type */ + type?: InputMaybe; + updatedAt?: InputMaybe; + /** Visibility */ + visibility?: InputMaybe; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociation = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** Message Id */ + message?: Maybe; + /** Message Channel Id */ + messageChannel?: Maybe; + /** Message Channel Id id foreign key */ + messageChannelId?: Maybe; + /** Message id from the messaging provider */ + messageExternalId?: Maybe; + /** Message Id id foreign key */ + messageId?: Maybe; + /** Message Thread Id */ + messageThread?: Maybe; + /** Thread id from the messaging provider */ + messageThreadExternalId?: Maybe; + /** Message Thread Id id foreign key */ + messageThreadId?: Maybe; + updatedAt: Scalars['DateTime']; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociationConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociationCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Message Channel Id id foreign key */ + messageChannelId?: InputMaybe; + /** Message id from the messaging provider */ + messageExternalId?: InputMaybe; + /** Message Id id foreign key */ + messageId?: InputMaybe; + /** Thread id from the messaging provider */ + messageThreadExternalId?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociationEdge = { + cursor: Scalars['Cursor']; + node: MessageChannelMessageAssociation; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociationFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Message Channel Id id foreign key */ + messageChannelId?: InputMaybe; + /** Message id from the messaging provider */ + messageExternalId?: InputMaybe; + /** Message Id id foreign key */ + messageId?: InputMaybe; + /** Thread id from the messaging provider */ + messageThreadExternalId?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociationOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Message Channel Id id foreign key */ + messageChannelId?: InputMaybe; + /** Message id from the messaging provider */ + messageExternalId?: InputMaybe; + /** Message Id id foreign key */ + messageId?: InputMaybe; + /** Thread id from the messaging provider */ + messageThreadExternalId?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Synced with a Message Channel */ +export type MessageChannelMessageAssociationUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Message Channel Id id foreign key */ + messageChannelId?: InputMaybe; + /** Message id from the messaging provider */ + messageExternalId?: InputMaybe; + /** Message Id id foreign key */ + messageId?: InputMaybe; + /** Thread id from the messaging provider */ + messageThreadExternalId?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Channels */ +export type MessageChannelOrderByInput = { + /** Connected Account id foreign key */ + connectedAccountId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Is Contact Auto Creation Enabled */ + isContactAutoCreationEnabled?: InputMaybe; + /** Channel Type */ + type?: InputMaybe; + updatedAt?: InputMaybe; + /** Visibility */ + visibility?: InputMaybe; +}; + +/** Channel Type */ +export enum MessageChannelTypeEnum { + /** Email */ + Email = 'email', + /** SMS */ + Sms = 'sms' +} + +export type MessageChannelTypeEnumFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + +/** Message Channels */ +export type MessageChannelUpdateInput = { + /** Connected Account id foreign key */ + connectedAccountId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Is Contact Auto Creation Enabled */ + isContactAutoCreationEnabled?: InputMaybe; + /** Channel Type */ + type?: InputMaybe; + updatedAt?: InputMaybe; + /** Visibility */ + visibility?: InputMaybe; +}; + +/** Visibility */ +export enum MessageChannelVisibilityEnum { + /** Metadata */ + Metadata = 'metadata', + /** Share Everything */ + ShareEverything = 'share_everything', + /** Subject */ + Subject = 'subject' +} + +export type MessageChannelVisibilityEnumFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + +/** Message */ +export type MessageConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** Message */ +export type MessageCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Message Direction */ + direction?: InputMaybe; + /** Message id from the message header */ + headerMessageId?: InputMaybe; + id?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + /** The date the message was received */ + receivedAt?: InputMaybe; + /** Subject */ + subject?: InputMaybe; + /** Text */ + text?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Direction */ +export enum MessageDirectionEnum { + /** Incoming */ + Incoming = 'incoming', + /** Outgoing */ + Outgoing = 'outgoing' +} + +export type MessageDirectionEnumFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + +/** Message */ +export type MessageEdge = { + cursor: Scalars['Cursor']; + node: Message; +}; + +/** Message */ +export type MessageFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Message Direction */ + direction?: InputMaybe; + /** Message id from the message header */ + headerMessageId?: InputMaybe; + id?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** The date the message was received */ + receivedAt?: InputMaybe; + /** Subject */ + subject?: InputMaybe; + /** Text */ + text?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message */ +export type MessageOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Message Direction */ + direction?: InputMaybe; + /** Message id from the message header */ + headerMessageId?: InputMaybe; + id?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + /** The date the message was received */ + receivedAt?: InputMaybe; + /** Subject */ + subject?: InputMaybe; + /** Text */ + text?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Participants */ +export type MessageParticipant = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Display Name */ + displayName: Scalars['String']; + /** Handle */ + handle: Scalars['String']; + id: Scalars['ID']; + /** Message */ + message?: Maybe; + /** Message id foreign key */ + messageId: Scalars['ID']; + /** Person */ + person?: Maybe; + /** Person id foreign key */ + personId?: Maybe; + /** Role */ + role: MessageParticipantRoleEnum; + updatedAt: Scalars['DateTime']; + /** Workspace member */ + workspaceMember?: Maybe; + /** Workspace member id foreign key */ + workspaceMemberId?: Maybe; +}; + +/** Message Participants */ +export type MessageParticipantConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** Message Participants */ +export type MessageParticipantCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Display Name */ + displayName?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Message id foreign key */ + messageId: Scalars['ID']; + /** Person id foreign key */ + personId?: InputMaybe; + /** Role */ + role?: InputMaybe; + updatedAt?: InputMaybe; + /** Workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** Message Participants */ +export type MessageParticipantEdge = { + cursor: Scalars['Cursor']; + node: MessageParticipant; +}; + +/** Message Participants */ +export type MessageParticipantFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Display Name */ + displayName?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Message id foreign key */ + messageId?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Person id foreign key */ + personId?: InputMaybe; + /** Role */ + role?: InputMaybe; + updatedAt?: InputMaybe; + /** Workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** Message Participants */ +export type MessageParticipantOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Display Name */ + displayName?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Message id foreign key */ + messageId?: InputMaybe; + /** Person id foreign key */ + personId?: InputMaybe; + /** Role */ + role?: InputMaybe; + updatedAt?: InputMaybe; + /** Workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** Role */ +export enum MessageParticipantRoleEnum { + /** Bcc */ + Bcc = 'bcc', + /** Cc */ + Cc = 'cc', + /** From */ + From = 'from', + /** To */ + To = 'to' +} + +export type MessageParticipantRoleEnumFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + +/** Message Participants */ +export type MessageParticipantUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Display Name */ + displayName?: InputMaybe; + /** Handle */ + handle?: InputMaybe; + id?: InputMaybe; + /** Message id foreign key */ + messageId?: InputMaybe; + /** Person id foreign key */ + personId?: InputMaybe; + /** Role */ + role?: InputMaybe; + updatedAt?: InputMaybe; + /** Workspace member id foreign key */ + workspaceMemberId?: InputMaybe; +}; + +/** Message Thread */ +export type MessageThread = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** Messages from the channel. */ + messageChannelMessageAssociations?: Maybe; + /** Messages from the thread. */ + messages?: Maybe; + updatedAt: Scalars['DateTime']; +}; + + +/** Message Thread */ +export type MessageThreadMessageChannelMessageAssociationsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** Message Thread */ +export type MessageThreadMessagesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** Message Thread */ +export type MessageThreadConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** Message Thread */ +export type MessageThreadCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Thread */ +export type MessageThreadEdge = { + cursor: Scalars['Cursor']; + node: MessageThread; +}; + +/** Message Thread */ +export type MessageThreadFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; +}; + +/** Message Thread */ +export type MessageThreadOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message Thread */ +export type MessageThreadUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Message */ +export type MessageUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Message Direction */ + direction?: InputMaybe; + /** Message id from the message header */ + headerMessageId?: InputMaybe; + id?: InputMaybe; + /** Message Thread Id id foreign key */ + messageThreadId?: InputMaybe; + /** The date the message was received */ + receivedAt?: InputMaybe; + /** Subject */ + subject?: InputMaybe; + /** Text */ + text?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type Mutation = { + activateWorkspace: Workspace; + challenge: LoginToken; + checkout: CheckoutEntity; + createActivities: Array; + createActivity: Activity; + createActivityTarget: ActivityTarget; + createActivityTargets: Array; + createApiKey: ApiKey; + createApiKeys: Array; + createAttachment: Attachment; + createAttachments: Array; + createBlocklist: Blocklist; + createBlocklists: Array; + createComment: Comment; + createComments: Array; + createCompanies: Array; + createCompany: Company; + createConnectedAccount: ConnectedAccount; + createConnectedAccounts: Array; + createEvent: Analytics; + createFavorite: Favorite; + createFavorites: Array; + createMessage: Message; + createMessageChannel: MessageChannel; + createMessageChannelMessageAssociation: MessageChannelMessageAssociation; + createMessageChannelMessageAssociations: Array; + createMessageChannels: Array; + createMessageParticipant: MessageParticipant; + createMessageParticipants: Array; + createMessageThread: MessageThread; + createMessageThreads: Array; + createMessages: Array; + createOneObject: Object; + createOneRefreshToken: RefreshToken; + createOpportunities: Array; + createOpportunity: Opportunity; + createPeople: Array; + createPerson: Person; + createPipelineStep: PipelineStep; + createPipelineSteps: Array; + createView: View; + createViewField: ViewField; + createViewFields: Array; + createViewFilter: ViewFilter; + createViewFilters: Array; + createViewSort: ViewSort; + createViewSorts: Array; + createViews: Array; + createWebhook: Webhook; + createWebhooks: Array; + createWorkspaceMember: WorkspaceMember; + createWorkspaceMembers: Array; + deleteActivities: Array; + deleteActivity: Activity; + deleteActivityTarget: ActivityTarget; + deleteActivityTargets: Array; + deleteApiKey: ApiKey; + deleteApiKeys: Array; + deleteAttachment: Attachment; + deleteAttachments: Array; + deleteBlocklist: Blocklist; + deleteBlocklists: Array; + deleteComment: Comment; + deleteComments: Array; + deleteCompanies: Array; + deleteCompany: Company; + deleteConnectedAccount: ConnectedAccount; + deleteConnectedAccounts: Array; + deleteCurrentWorkspace: Workspace; + deleteFavorite: Favorite; + deleteFavorites: Array; + deleteMessage: Message; + deleteMessageChannel: MessageChannel; + deleteMessageChannelMessageAssociation: MessageChannelMessageAssociation; + deleteMessageChannelMessageAssociations: Array; + deleteMessageChannels: Array; + deleteMessageParticipant: MessageParticipant; + deleteMessageParticipants: Array; + deleteMessageThread: MessageThread; + deleteMessageThreads: Array; + deleteMessages: Array; + deleteOneObject: Object; + deleteOpportunities: Array; + deleteOpportunity: Opportunity; + deletePeople: Array; + deletePerson: Person; + deletePipelineStep: PipelineStep; + deletePipelineSteps: Array; + deleteUser: User; + deleteView: View; + deleteViewField: ViewField; + deleteViewFields: Array; + deleteViewFilter: ViewFilter; + deleteViewFilters: Array; + deleteViewSort: ViewSort; + deleteViewSorts: Array; + deleteViews: Array; + deleteWebhook: Webhook; + deleteWebhooks: Array; + deleteWorkspaceMember: WorkspaceMember; + deleteWorkspaceMembers: Array; + emailPasswordResetLink: EmailPasswordResetLink; + executeQuickActionOnActivity: Activity; + executeQuickActionOnActivityTarget: ActivityTarget; + executeQuickActionOnApiKey: ApiKey; + executeQuickActionOnAttachment: Attachment; + executeQuickActionOnBlocklist: Blocklist; + executeQuickActionOnComment: Comment; + executeQuickActionOnCompany: Company; + executeQuickActionOnConnectedAccount: ConnectedAccount; + executeQuickActionOnFavorite: Favorite; + executeQuickActionOnMessage: Message; + executeQuickActionOnMessageChannel: MessageChannel; + executeQuickActionOnMessageChannelMessageAssociation: MessageChannelMessageAssociation; + executeQuickActionOnMessageParticipant: MessageParticipant; + executeQuickActionOnMessageThread: MessageThread; + executeQuickActionOnOpportunity: Opportunity; + executeQuickActionOnPerson: Person; + executeQuickActionOnPipelineStep: PipelineStep; + executeQuickActionOnView: View; + executeQuickActionOnViewField: ViewField; + executeQuickActionOnViewFilter: ViewFilter; + executeQuickActionOnViewSort: ViewSort; + executeQuickActionOnWebhook: Webhook; + executeQuickActionOnWorkspaceMember: WorkspaceMember; + generateApiKeyToken: ApiKeyToken; + generateTransientToken: TransientToken; + impersonate: Verify; + renewToken: AuthTokens; + signUp: LoginToken; + updateActivities: Array; + updateActivity: Activity; + updateActivityTarget: ActivityTarget; + updateActivityTargets: Array; + updateApiKey: ApiKey; + updateApiKeys: Array; + updateAttachment: Attachment; + updateAttachments: Array; + updateBlocklist: Blocklist; + updateBlocklists: Array; + updateComment: Comment; + updateComments: Array; + updateCompanies: Array; + updateCompany: Company; + updateConnectedAccount: ConnectedAccount; + updateConnectedAccounts: Array; + updateFavorite: Favorite; + updateFavorites: Array; + updateMessage: Message; + updateMessageChannel: MessageChannel; + updateMessageChannelMessageAssociation: MessageChannelMessageAssociation; + updateMessageChannelMessageAssociations: Array; + updateMessageChannels: Array; + updateMessageParticipant: MessageParticipant; + updateMessageParticipants: Array; + updateMessageThread: MessageThread; + updateMessageThreads: Array; + updateMessages: Array; + updateOneObject: Object; + updateOpportunities: Array; + updateOpportunity: Opportunity; + updatePasswordViaResetToken: InvalidatePassword; + updatePeople: Array; + updatePerson: Person; + updatePipelineStep: PipelineStep; + updatePipelineSteps: Array; + updateView: View; + updateViewField: ViewField; + updateViewFields: Array; + updateViewFilter: ViewFilter; + updateViewFilters: Array; + updateViewSort: ViewSort; + updateViewSorts: Array; + updateViews: Array; + updateWebhook: Webhook; + updateWebhooks: Array; + updateWorkspace: Workspace; + updateWorkspaceMember: WorkspaceMember; + updateWorkspaceMembers: Array; + uploadFile: Scalars['String']; + uploadImage: Scalars['String']; + uploadProfilePicture: Scalars['String']; + uploadWorkspaceLogo: Scalars['String']; + verify: Verify; +}; + + +export type MutationActivateWorkspaceArgs = { + data: ActivateWorkspaceInput; +}; + + +export type MutationChallengeArgs = { + email: Scalars['String']; + password: Scalars['String']; +}; + + +export type MutationCheckoutArgs = { + recurringInterval: Scalars['String']; + successUrlPath?: InputMaybe; +}; + + +export type MutationCreateActivitiesArgs = { + data: Array; +}; + + +export type MutationCreateActivityArgs = { + data: ActivityCreateInput; +}; + + +export type MutationCreateActivityTargetArgs = { + data: ActivityTargetCreateInput; +}; + + +export type MutationCreateActivityTargetsArgs = { + data: Array; +}; + + +export type MutationCreateApiKeyArgs = { + data: ApiKeyCreateInput; +}; + + +export type MutationCreateApiKeysArgs = { + data: Array; +}; + + +export type MutationCreateAttachmentArgs = { + data: AttachmentCreateInput; +}; + + +export type MutationCreateAttachmentsArgs = { + data: Array; +}; + + +export type MutationCreateBlocklistArgs = { + data: BlocklistCreateInput; +}; + + +export type MutationCreateBlocklistsArgs = { + data: Array; +}; + + +export type MutationCreateCommentArgs = { + data: CommentCreateInput; +}; + + +export type MutationCreateCommentsArgs = { + data: Array; +}; + + +export type MutationCreateCompaniesArgs = { + data: Array; +}; + + +export type MutationCreateCompanyArgs = { + data: CompanyCreateInput; +}; + + +export type MutationCreateConnectedAccountArgs = { + data: ConnectedAccountCreateInput; +}; + + +export type MutationCreateConnectedAccountsArgs = { + data: Array; +}; + + +export type MutationCreateEventArgs = { + data: Scalars['JSON']; + type: Scalars['String']; +}; + + +export type MutationCreateFavoriteArgs = { + data: FavoriteCreateInput; +}; + + +export type MutationCreateFavoritesArgs = { + data: Array; +}; + + +export type MutationCreateMessageArgs = { + data: MessageCreateInput; +}; + + +export type MutationCreateMessageChannelArgs = { + data: MessageChannelCreateInput; +}; + + +export type MutationCreateMessageChannelMessageAssociationArgs = { + data: MessageChannelMessageAssociationCreateInput; +}; + + +export type MutationCreateMessageChannelMessageAssociationsArgs = { + data: Array; +}; + + +export type MutationCreateMessageChannelsArgs = { + data: Array; +}; + + +export type MutationCreateMessageParticipantArgs = { + data: MessageParticipantCreateInput; +}; + + +export type MutationCreateMessageParticipantsArgs = { + data: Array; +}; + + +export type MutationCreateMessageThreadArgs = { + data: MessageThreadCreateInput; +}; + + +export type MutationCreateMessageThreadsArgs = { + data: Array; +}; + + +export type MutationCreateMessagesArgs = { + data: Array; +}; + + +export type MutationCreateOpportunitiesArgs = { + data: Array; +}; + + +export type MutationCreateOpportunityArgs = { + data: OpportunityCreateInput; +}; + + +export type MutationCreatePeopleArgs = { + data: Array; +}; + + +export type MutationCreatePersonArgs = { + data: PersonCreateInput; +}; + + +export type MutationCreatePipelineStepArgs = { + data: PipelineStepCreateInput; +}; + + +export type MutationCreatePipelineStepsArgs = { + data: Array; +}; + + +export type MutationCreateViewArgs = { + data: ViewCreateInput; +}; + + +export type MutationCreateViewFieldArgs = { + data: ViewFieldCreateInput; +}; + + +export type MutationCreateViewFieldsArgs = { + data: Array; +}; + + +export type MutationCreateViewFilterArgs = { + data: ViewFilterCreateInput; +}; + + +export type MutationCreateViewFiltersArgs = { + data: Array; +}; + + +export type MutationCreateViewSortArgs = { + data: ViewSortCreateInput; +}; + + +export type MutationCreateViewSortsArgs = { + data: Array; +}; + + +export type MutationCreateViewsArgs = { + data: Array; +}; + + +export type MutationCreateWebhookArgs = { + data: WebhookCreateInput; +}; + + +export type MutationCreateWebhooksArgs = { + data: Array; +}; + + +export type MutationCreateWorkspaceMemberArgs = { + data: WorkspaceMemberCreateInput; +}; + + +export type MutationCreateWorkspaceMembersArgs = { + data: Array; +}; + + +export type MutationDeleteActivitiesArgs = { + filter: ActivityFilterInput; +}; + + +export type MutationDeleteActivityArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteActivityTargetArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteActivityTargetsArgs = { + filter: ActivityTargetFilterInput; +}; + + +export type MutationDeleteApiKeyArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteApiKeysArgs = { + filter: ApiKeyFilterInput; +}; + + +export type MutationDeleteAttachmentArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteAttachmentsArgs = { + filter: AttachmentFilterInput; +}; + + +export type MutationDeleteBlocklistArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteBlocklistsArgs = { + filter: BlocklistFilterInput; +}; + + +export type MutationDeleteCommentArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteCommentsArgs = { + filter: CommentFilterInput; +}; + + +export type MutationDeleteCompaniesArgs = { + filter: CompanyFilterInput; +}; + + +export type MutationDeleteCompanyArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteConnectedAccountArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteConnectedAccountsArgs = { + filter: ConnectedAccountFilterInput; +}; + + +export type MutationDeleteFavoriteArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteFavoritesArgs = { + filter: FavoriteFilterInput; +}; + + +export type MutationDeleteMessageArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteMessageChannelArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteMessageChannelMessageAssociationArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteMessageChannelMessageAssociationsArgs = { + filter: MessageChannelMessageAssociationFilterInput; +}; + + +export type MutationDeleteMessageChannelsArgs = { + filter: MessageChannelFilterInput; +}; + + +export type MutationDeleteMessageParticipantArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteMessageParticipantsArgs = { + filter: MessageParticipantFilterInput; +}; + + +export type MutationDeleteMessageThreadArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteMessageThreadsArgs = { + filter: MessageThreadFilterInput; +}; + + +export type MutationDeleteMessagesArgs = { + filter: MessageFilterInput; +}; + + +export type MutationDeleteOneObjectArgs = { + input: DeleteOneObjectInput; +}; + + +export type MutationDeleteOpportunitiesArgs = { + filter: OpportunityFilterInput; +}; + + +export type MutationDeleteOpportunityArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeletePeopleArgs = { + filter: PersonFilterInput; +}; + + +export type MutationDeletePersonArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeletePipelineStepArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeletePipelineStepsArgs = { + filter: PipelineStepFilterInput; +}; + + +export type MutationDeleteViewArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteViewFieldArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteViewFieldsArgs = { + filter: ViewFieldFilterInput; +}; + + +export type MutationDeleteViewFilterArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteViewFiltersArgs = { + filter: ViewFilterFilterInput; +}; + + +export type MutationDeleteViewSortArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteViewSortsArgs = { + filter: ViewSortFilterInput; +}; + + +export type MutationDeleteViewsArgs = { + filter: ViewFilterInput; +}; + + +export type MutationDeleteWebhookArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteWebhooksArgs = { + filter: WebhookFilterInput; +}; + + +export type MutationDeleteWorkspaceMemberArgs = { + id: Scalars['ID']; +}; + + +export type MutationDeleteWorkspaceMembersArgs = { + filter: WorkspaceMemberFilterInput; +}; + + +export type MutationEmailPasswordResetLinkArgs = { + email: Scalars['String']; +}; + + +export type MutationExecuteQuickActionOnActivityArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnActivityTargetArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnApiKeyArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnAttachmentArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnBlocklistArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnCommentArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnCompanyArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnConnectedAccountArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnFavoriteArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnMessageArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnMessageChannelArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnMessageChannelMessageAssociationArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnMessageParticipantArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnMessageThreadArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnOpportunityArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnPersonArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnPipelineStepArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnViewArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnViewFieldArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnViewFilterArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnViewSortArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnWebhookArgs = { + id: Scalars['ID']; +}; + + +export type MutationExecuteQuickActionOnWorkspaceMemberArgs = { + id: Scalars['ID']; +}; + + +export type MutationGenerateApiKeyTokenArgs = { + apiKeyId: Scalars['String']; + expiresAt: Scalars['String']; +}; + + +export type MutationImpersonateArgs = { + userId: Scalars['String']; +}; + + +export type MutationRenewTokenArgs = { + refreshToken: Scalars['String']; +}; + + +export type MutationSignUpArgs = { + email: Scalars['String']; + password: Scalars['String']; + workspaceInviteHash?: InputMaybe; +}; + + +export type MutationUpdateActivitiesArgs = { + data: ActivityUpdateInput; + filter: ActivityFilterInput; +}; + + +export type MutationUpdateActivityArgs = { + data: ActivityUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateActivityTargetArgs = { + data: ActivityTargetUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateActivityTargetsArgs = { + data: ActivityTargetUpdateInput; + filter: ActivityTargetFilterInput; +}; + + +export type MutationUpdateApiKeyArgs = { + data: ApiKeyUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateApiKeysArgs = { + data: ApiKeyUpdateInput; + filter: ApiKeyFilterInput; +}; + + +export type MutationUpdateAttachmentArgs = { + data: AttachmentUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateAttachmentsArgs = { + data: AttachmentUpdateInput; + filter: AttachmentFilterInput; +}; + + +export type MutationUpdateBlocklistArgs = { + data: BlocklistUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateBlocklistsArgs = { + data: BlocklistUpdateInput; + filter: BlocklistFilterInput; +}; + + +export type MutationUpdateCommentArgs = { + data: CommentUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateCommentsArgs = { + data: CommentUpdateInput; + filter: CommentFilterInput; +}; + + +export type MutationUpdateCompaniesArgs = { + data: CompanyUpdateInput; + filter: CompanyFilterInput; +}; + + +export type MutationUpdateCompanyArgs = { + data: CompanyUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateConnectedAccountArgs = { + data: ConnectedAccountUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateConnectedAccountsArgs = { + data: ConnectedAccountUpdateInput; + filter: ConnectedAccountFilterInput; +}; + + +export type MutationUpdateFavoriteArgs = { + data: FavoriteUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateFavoritesArgs = { + data: FavoriteUpdateInput; + filter: FavoriteFilterInput; +}; + + +export type MutationUpdateMessageArgs = { + data: MessageUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateMessageChannelArgs = { + data: MessageChannelUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateMessageChannelMessageAssociationArgs = { + data: MessageChannelMessageAssociationUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateMessageChannelMessageAssociationsArgs = { + data: MessageChannelMessageAssociationUpdateInput; + filter: MessageChannelMessageAssociationFilterInput; +}; + + +export type MutationUpdateMessageChannelsArgs = { + data: MessageChannelUpdateInput; + filter: MessageChannelFilterInput; +}; + + +export type MutationUpdateMessageParticipantArgs = { + data: MessageParticipantUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateMessageParticipantsArgs = { + data: MessageParticipantUpdateInput; + filter: MessageParticipantFilterInput; +}; + + +export type MutationUpdateMessageThreadArgs = { + data: MessageThreadUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateMessageThreadsArgs = { + data: MessageThreadUpdateInput; + filter: MessageThreadFilterInput; +}; + + +export type MutationUpdateMessagesArgs = { + data: MessageUpdateInput; + filter: MessageFilterInput; +}; + + +export type MutationUpdateOpportunitiesArgs = { + data: OpportunityUpdateInput; + filter: OpportunityFilterInput; +}; + + +export type MutationUpdateOpportunityArgs = { + data: OpportunityUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdatePasswordViaResetTokenArgs = { + newPassword: Scalars['String']; + passwordResetToken: Scalars['String']; +}; + + +export type MutationUpdatePeopleArgs = { + data: PersonUpdateInput; + filter: PersonFilterInput; +}; + + +export type MutationUpdatePersonArgs = { + data: PersonUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdatePipelineStepArgs = { + data: PipelineStepUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdatePipelineStepsArgs = { + data: PipelineStepUpdateInput; + filter: PipelineStepFilterInput; +}; + + +export type MutationUpdateViewArgs = { + data: ViewUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateViewFieldArgs = { + data: ViewFieldUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateViewFieldsArgs = { + data: ViewFieldUpdateInput; + filter: ViewFieldFilterInput; +}; + + +export type MutationUpdateViewFilterArgs = { + data: ViewFilterUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateViewFiltersArgs = { + data: ViewFilterUpdateInput; + filter: ViewFilterFilterInput; +}; + + +export type MutationUpdateViewSortArgs = { + data: ViewSortUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateViewSortsArgs = { + data: ViewSortUpdateInput; + filter: ViewSortFilterInput; +}; + + +export type MutationUpdateViewsArgs = { + data: ViewUpdateInput; + filter: ViewFilterInput; +}; + + +export type MutationUpdateWebhookArgs = { + data: WebhookUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateWebhooksArgs = { + data: WebhookUpdateInput; + filter: WebhookFilterInput; +}; + + +export type MutationUpdateWorkspaceArgs = { + data: UpdateWorkspaceInput; +}; + + +export type MutationUpdateWorkspaceMemberArgs = { + data: WorkspaceMemberUpdateInput; + id: Scalars['ID']; +}; + + +export type MutationUpdateWorkspaceMembersArgs = { + data: WorkspaceMemberUpdateInput; + filter: WorkspaceMemberFilterInput; +}; + + +export type MutationUploadFileArgs = { + file: Scalars['Upload']; + fileFolder?: InputMaybe; +}; + + +export type MutationUploadImageArgs = { + file: Scalars['Upload']; + fileFolder?: InputMaybe; +}; + + +export type MutationUploadProfilePictureArgs = { + file: Scalars['Upload']; +}; + + +export type MutationUploadWorkspaceLogoArgs = { + file: Scalars['Upload']; +}; + + +export type MutationVerifyArgs = { + loginToken: Scalars['String']; +}; + +export type ObjectConnection = { + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; + /** Fetch total count of records */ + totalCount: Scalars['Int']; +}; + +export type ObjectFieldsConnection = { + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; + /** Fetch total count of records */ + totalCount: Scalars['Int']; +}; + +/** An opportunity */ +export type Opportunity = { + /** Activities tied to the opportunity */ + activityTargets?: Maybe; + /** Opportunity amount */ + amount?: Maybe; + /** Attachments linked to the opportunity. */ + attachments?: Maybe; + /** Opportunity close date */ + closeDate?: Maybe; + /** Opportunity company */ + company?: Maybe; + /** Opportunity company id foreign key */ + companyId?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Favorites linked to the opportunity */ + favorites?: Maybe; + id: Scalars['ID']; + /** The opportunity name */ + name: Scalars['String']; + /** Opportunity pipeline step */ + pipelineStep?: Maybe; + /** Opportunity pipeline step id foreign key */ + pipelineStepId?: Maybe; + /** Opportunity point of contact */ + pointOfContact?: Maybe; + /** Opportunity point of contact id foreign key */ + pointOfContactId?: Maybe; + /** Position */ + position?: Maybe; + /** Opportunity probability */ + probability: Scalars['String']; + /** Opportunity stage */ + stage: OpportunityStageEnum; + updatedAt: Scalars['DateTime']; +}; + + +/** An opportunity */ +export type OpportunityActivityTargetsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** An opportunity */ +export type OpportunityAttachmentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** An opportunity */ +export type OpportunityFavoritesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** An opportunity */ +export type OpportunityConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** An opportunity */ +export type OpportunityCreateInput = { + /** Opportunity amount */ + amount?: InputMaybe; + /** Opportunity close date */ + closeDate?: InputMaybe; + /** Opportunity company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** The opportunity name */ + name?: InputMaybe; + /** Opportunity pipeline step id foreign key */ + pipelineStepId?: InputMaybe; + /** Opportunity point of contact id foreign key */ + pointOfContactId?: InputMaybe; + /** Position */ + position?: InputMaybe; + /** Opportunity probability */ + probability?: InputMaybe; + /** Opportunity stage */ + stage?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An opportunity */ +export type OpportunityEdge = { + cursor: Scalars['Cursor']; + node: Opportunity; +}; + +/** An opportunity */ +export type OpportunityFilterInput = { + /** Opportunity amount */ + amount?: InputMaybe; + and?: InputMaybe>>; + /** Opportunity close date */ + closeDate?: InputMaybe; + /** Opportunity company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** The opportunity name */ + name?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Opportunity pipeline step id foreign key */ + pipelineStepId?: InputMaybe; + /** Opportunity point of contact id foreign key */ + pointOfContactId?: InputMaybe; + /** Position */ + position?: InputMaybe; + /** Opportunity probability */ + probability?: InputMaybe; + /** Opportunity stage */ + stage?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** An opportunity */ +export type OpportunityOrderByInput = { + /** Opportunity amount */ + amount?: InputMaybe; + /** Opportunity close date */ + closeDate?: InputMaybe; + /** Opportunity company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** The opportunity name */ + name?: InputMaybe; + /** Opportunity pipeline step id foreign key */ + pipelineStepId?: InputMaybe; + /** Opportunity point of contact id foreign key */ + pointOfContactId?: InputMaybe; + /** Position */ + position?: InputMaybe; + /** Opportunity probability */ + probability?: InputMaybe; + /** Opportunity stage */ + stage?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** Opportunity stage */ +export enum OpportunityStageEnum { + /** Customer */ + Customer = 'CUSTOMER', + /** Meeting */ + Meeting = 'MEETING', + /** New */ + New = 'NEW', + /** Proposal */ + Proposal = 'PROPOSAL', + /** Screening */ + Screening = 'SCREENING' +} + +export type OpportunityStageEnumFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + +/** An opportunity */ +export type OpportunityUpdateInput = { + /** Opportunity amount */ + amount?: InputMaybe; + /** Opportunity close date */ + closeDate?: InputMaybe; + /** Opportunity company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** The opportunity name */ + name?: InputMaybe; + /** Opportunity pipeline step id foreign key */ + pipelineStepId?: InputMaybe; + /** Opportunity point of contact id foreign key */ + pointOfContactId?: InputMaybe; + /** Position */ + position?: InputMaybe; + /** Opportunity probability */ + probability?: InputMaybe; + /** Opportunity stage */ + stage?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** This enum is used to specify the order of results */ +export enum OrderByDirection { + /** Ascending order, nulls first */ + AscNullsFirst = 'AscNullsFirst', + /** Ascending order, nulls last */ + AscNullsLast = 'AscNullsLast', + /** Descending order, nulls first */ + DescNullsFirst = 'DescNullsFirst', + /** Descending order, nulls last */ + DescNullsLast = 'DescNullsLast' +} + +export type PageInfo = { + /** The cursor of the last returned record. */ + endCursor?: Maybe; + /** true if paging forward and there are more records. */ + hasNextPage: Scalars['Boolean']; + /** true if paging backwards and there are more records. */ + hasPreviousPage: Scalars['Boolean']; + /** The cursor of the first returned record. */ + startCursor?: Maybe; +}; + +/** A person */ +export type Person = { + /** Activities tied to the contact */ + activityTargets?: Maybe; + /** Attachments linked to the contact. */ + attachments?: Maybe; + /** Contact’s avatar */ + avatarUrl: Scalars['String']; + /** Contact’s city */ + city: Scalars['String']; + /** Contact’s company */ + company?: Maybe; + /** Contact’s company id foreign key */ + companyId?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Contact’s Email */ + email: Scalars['String']; + /** Favorites linked to the contact */ + favorites?: Maybe; + id: Scalars['ID']; + /** Contact’s job title */ + jobTitle: Scalars['String']; + /** Contact’s Linkedin account */ + linkedinLink?: Maybe; + /** Message Participants */ + messageParticipants?: Maybe; + /** Contact’s name */ + name?: Maybe; + /** Contact’s phone number */ + phone: Scalars['String']; + /** Point of Contact for Opportunities */ + pointOfContactForOpportunities?: Maybe; + /** Record Position */ + position?: Maybe; + updatedAt: Scalars['DateTime']; + /** Contact’s X/Twitter account */ + xLink?: Maybe; +}; + + +/** A person */ +export type PersonActivityTargetsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A person */ +export type PersonAttachmentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A person */ +export type PersonFavoritesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A person */ +export type PersonMessageParticipantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A person */ +export type PersonPointOfContactForOpportunitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** A person */ +export type PersonConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A person */ +export type PersonCreateInput = { + /** Contact’s avatar */ + avatarUrl?: InputMaybe; + /** Contact’s city */ + city?: InputMaybe; + /** Contact’s company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Contact’s Email */ + email?: InputMaybe; + id?: InputMaybe; + /** Contact’s job title */ + jobTitle?: InputMaybe; + /** Contact’s Linkedin account */ + linkedinLink?: InputMaybe; + /** Contact’s name */ + name?: InputMaybe; + /** Contact’s phone number */ + phone?: InputMaybe; + /** Record Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Contact’s X/Twitter account */ + xLink?: InputMaybe; +}; + +/** A person */ +export type PersonEdge = { + cursor: Scalars['Cursor']; + node: Person; +}; + +/** A person */ +export type PersonFilterInput = { + and?: InputMaybe>>; + /** Contact’s avatar */ + avatarUrl?: InputMaybe; + /** Contact’s city */ + city?: InputMaybe; + /** Contact’s company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Contact’s Email */ + email?: InputMaybe; + id?: InputMaybe; + /** Contact’s job title */ + jobTitle?: InputMaybe; + /** Contact’s Linkedin account */ + linkedinLink?: InputMaybe; + /** Contact’s name */ + name?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Contact’s phone number */ + phone?: InputMaybe; + /** Record Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Contact’s X/Twitter account */ + xLink?: InputMaybe; +}; + +/** A person */ +export type PersonOrderByInput = { + /** Contact’s avatar */ + avatarUrl?: InputMaybe; + /** Contact’s city */ + city?: InputMaybe; + /** Contact’s company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Contact’s Email */ + email?: InputMaybe; + id?: InputMaybe; + /** Contact’s job title */ + jobTitle?: InputMaybe; + /** Contact’s Linkedin account */ + linkedinLink?: InputMaybe; + /** Contact’s name */ + name?: InputMaybe; + /** Contact’s phone number */ + phone?: InputMaybe; + /** Record Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Contact’s X/Twitter account */ + xLink?: InputMaybe; +}; + +/** A person */ +export type PersonUpdateInput = { + /** Contact’s avatar */ + avatarUrl?: InputMaybe; + /** Contact’s city */ + city?: InputMaybe; + /** Contact’s company id foreign key */ + companyId?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** Contact’s Email */ + email?: InputMaybe; + id?: InputMaybe; + /** Contact’s job title */ + jobTitle?: InputMaybe; + /** Contact’s Linkedin account */ + linkedinLink?: InputMaybe; + /** Contact’s name */ + name?: InputMaybe; + /** Contact’s phone number */ + phone?: InputMaybe; + /** Record Position */ + position?: InputMaybe; + updatedAt?: InputMaybe; + /** Contact’s X/Twitter account */ + xLink?: InputMaybe; +}; + +/** A pipeline step */ +export type PipelineStep = { + /** Pipeline Step color */ + color: Scalars['String']; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** Pipeline Step name */ + name: Scalars['String']; + /** Opportunities linked to the step. */ + opportunities?: Maybe; + /** Pipeline Step position */ + position?: Maybe; + updatedAt: Scalars['DateTime']; +}; + + +/** A pipeline step */ +export type PipelineStepOpportunitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** A pipeline step */ +export type PipelineStepConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A pipeline step */ +export type PipelineStepCreateInput = { + /** Pipeline Step color */ + color?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Pipeline Step name */ + name?: InputMaybe; + /** Pipeline Step position */ + position?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A pipeline step */ +export type PipelineStepEdge = { + cursor: Scalars['Cursor']; + node: PipelineStep; +}; + +/** A pipeline step */ +export type PipelineStepFilterInput = { + and?: InputMaybe>>; + /** Pipeline Step color */ + color?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Pipeline Step name */ + name?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** Pipeline Step position */ + position?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A pipeline step */ +export type PipelineStepOrderByInput = { + /** Pipeline Step color */ + color?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Pipeline Step name */ + name?: InputMaybe; + /** Pipeline Step position */ + position?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A pipeline step */ +export type PipelineStepUpdateInput = { + /** Pipeline Step color */ + color?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Pipeline Step name */ + name?: InputMaybe; + /** Pipeline Step position */ + position?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type ProductPriceEntity = { + created: Scalars['Float']; + recurringInterval: Scalars['String']; + stripePriceId: Scalars['String']; + unitAmount: Scalars['Float']; +}; + +export type ProductPricesEntity = { + productPrices: Array; + totalNumberOfPrices: Scalars['Int']; +}; + +export type Query = { + activities: ActivityConnection; + activity: Activity; + activityDuplicates: ActivityConnection; + activityTarget: ActivityTarget; + activityTargetDuplicates: ActivityTargetConnection; + activityTargets: ActivityTargetConnection; + apiKey: ApiKey; + apiKeyDuplicates: ApiKeyConnection; + apiKeys: ApiKeyConnection; + attachment: Attachment; + attachmentDuplicates: AttachmentConnection; + attachments: AttachmentConnection; + blocklist: Blocklist; + blocklistDuplicates: BlocklistConnection; + blocklists: BlocklistConnection; + checkUserExists: UserExists; + checkWorkspaceInviteHashIsValid: WorkspaceInviteHashValid; + clientConfig: ClientConfig; + comment: Comment; + commentDuplicates: CommentConnection; + comments: CommentConnection; + companies: CompanyConnection; + company: Company; + companyDuplicates: CompanyConnection; + connectedAccount: ConnectedAccount; + connectedAccountDuplicates: ConnectedAccountConnection; + connectedAccounts: ConnectedAccountConnection; + currentUser: User; + currentWorkspace: Workspace; + favorite: Favorite; + favoriteDuplicates: FavoriteConnection; + favorites: FavoriteConnection; + findWorkspaceFromInviteHash: Workspace; + getProductPrices: ProductPricesEntity; + getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; + getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; + message: Message; + messageChannel: MessageChannel; + messageChannelDuplicates: MessageChannelConnection; + messageChannelMessageAssociation: MessageChannelMessageAssociation; + messageChannelMessageAssociationDuplicates: MessageChannelMessageAssociationConnection; + messageChannelMessageAssociations: MessageChannelMessageAssociationConnection; + messageChannels: MessageChannelConnection; + messageDuplicates: MessageConnection; + messageParticipant: MessageParticipant; + messageParticipantDuplicates: MessageParticipantConnection; + messageParticipants: MessageParticipantConnection; + messageThread: MessageThread; + messageThreadDuplicates: MessageThreadConnection; + messageThreads: MessageThreadConnection; + messages: MessageConnection; + object: Object; + objects: ObjectConnection; + opportunities: OpportunityConnection; + opportunity: Opportunity; + opportunityDuplicates: OpportunityConnection; + people: PersonConnection; + person: Person; + personDuplicates: PersonConnection; + pipelineStep: PipelineStep; + pipelineStepDuplicates: PipelineStepConnection; + pipelineSteps: PipelineStepConnection; + validatePasswordResetToken: ValidatePasswordResetToken; + view: View; + viewDuplicates: ViewConnection; + viewField: ViewField; + viewFieldDuplicates: ViewFieldConnection; + viewFields: ViewFieldConnection; + viewFilter: ViewFilter; + viewFilterDuplicates: ViewFilterConnection; + viewFilters: ViewFilterConnection; + viewSort: ViewSort; + viewSortDuplicates: ViewSortConnection; + viewSorts: ViewSortConnection; + views: ViewConnection; + webhook: Webhook; + webhookDuplicates: WebhookConnection; + webhooks: WebhookConnection; + workspaceMember: WorkspaceMember; + workspaceMemberDuplicates: WorkspaceMemberConnection; + workspaceMembers: WorkspaceMemberConnection; +}; + + +export type QueryActivitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryActivityArgs = { + filter: ActivityFilterInput; +}; + + +export type QueryActivityDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryActivityTargetArgs = { + filter: ActivityTargetFilterInput; +}; + + +export type QueryActivityTargetDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryActivityTargetsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryApiKeyArgs = { + filter: ApiKeyFilterInput; +}; + + +export type QueryApiKeyDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryApiKeysArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryAttachmentArgs = { + filter: AttachmentFilterInput; +}; + + +export type QueryAttachmentDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryAttachmentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryBlocklistArgs = { + filter: BlocklistFilterInput; +}; + + +export type QueryBlocklistDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryBlocklistsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryCheckUserExistsArgs = { + email: Scalars['String']; +}; + + +export type QueryCheckWorkspaceInviteHashIsValidArgs = { + inviteHash: Scalars['String']; +}; + + +export type QueryCommentArgs = { + filter: CommentFilterInput; +}; + + +export type QueryCommentDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryCommentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryCompaniesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryCompanyArgs = { + filter: CompanyFilterInput; +}; + + +export type QueryCompanyDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryConnectedAccountArgs = { + filter: ConnectedAccountFilterInput; +}; + + +export type QueryConnectedAccountDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryConnectedAccountsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryFavoriteArgs = { + filter: FavoriteFilterInput; +}; + + +export type QueryFavoriteDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryFavoritesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryFindWorkspaceFromInviteHashArgs = { + inviteHash: Scalars['String']; +}; + + +export type QueryGetProductPricesArgs = { + product: Scalars['String']; +}; + + +export type QueryGetTimelineThreadsFromCompanyIdArgs = { + companyId: Scalars['ID']; + page: Scalars['Int']; + pageSize: Scalars['Int']; +}; + + +export type QueryGetTimelineThreadsFromPersonIdArgs = { + page: Scalars['Int']; + pageSize: Scalars['Int']; + personId: Scalars['ID']; +}; + + +export type QueryMessageArgs = { + filter: MessageFilterInput; +}; + + +export type QueryMessageChannelArgs = { + filter: MessageChannelFilterInput; +}; + + +export type QueryMessageChannelDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryMessageChannelMessageAssociationArgs = { + filter: MessageChannelMessageAssociationFilterInput; +}; + + +export type QueryMessageChannelMessageAssociationDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryMessageChannelMessageAssociationsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryMessageChannelsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryMessageDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryMessageParticipantArgs = { + filter: MessageParticipantFilterInput; +}; + + +export type QueryMessageParticipantDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryMessageParticipantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryMessageThreadArgs = { + filter: MessageThreadFilterInput; +}; + + +export type QueryMessageThreadDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryMessageThreadsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryMessagesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryOpportunitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryOpportunityArgs = { + filter: OpportunityFilterInput; +}; + + +export type QueryOpportunityDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryPeopleArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryPersonArgs = { + filter: PersonFilterInput; +}; + + +export type QueryPersonDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryPipelineStepArgs = { + filter: PipelineStepFilterInput; +}; + + +export type QueryPipelineStepDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryPipelineStepsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryValidatePasswordResetTokenArgs = { + passwordResetToken: Scalars['String']; +}; + + +export type QueryViewArgs = { + filter: ViewFilterInput; +}; + + +export type QueryViewDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryViewFieldArgs = { + filter: ViewFieldFilterInput; +}; + + +export type QueryViewFieldDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryViewFieldsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryViewFilterArgs = { + filter: ViewFilterFilterInput; +}; + + +export type QueryViewFilterDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryViewFiltersArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryViewSortArgs = { + filter: ViewSortFilterInput; +}; + + +export type QueryViewSortDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryViewSortsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryViewsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryWebhookArgs = { + filter: WebhookFilterInput; +}; + + +export type QueryWebhookDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryWebhooksArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +export type QueryWorkspaceMemberArgs = { + filter: WorkspaceMemberFilterInput; +}; + + +export type QueryWorkspaceMemberDuplicatesArgs = { + data?: InputMaybe; + id?: InputMaybe; +}; + + +export type QueryWorkspaceMembersArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +export type RefreshToken = { + createdAt: Scalars['DateTime']; + expiresAt: Scalars['DateTime']; + id: Scalars['ID']; + updatedAt: Scalars['DateTime']; +}; + +export type RefreshTokenEdge = { + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the RefreshToken */ + node: RefreshToken; +}; + +export type RelationConnection = { + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; + /** Fetch total count of records */ + totalCount: Scalars['Int']; +}; + +export type RelationDeleteResponse = { + createdAt?: Maybe; + fromFieldMetadataId?: Maybe; + fromObjectMetadataId?: Maybe; + id?: Maybe; + relationType?: Maybe; + toFieldMetadataId?: Maybe; + toObjectMetadataId?: Maybe; + updatedAt?: Maybe; +}; + +/** Type of the relation */ +export enum RelationMetadataType { + ManyToMany = 'MANY_TO_MANY', + OneToMany = 'ONE_TO_MANY', + OneToOne = 'ONE_TO_ONE' +} + +export type Sentry = { + dsn?: Maybe; +}; + +/** Sort Directions */ +export enum SortDirection { + Asc = 'ASC', + Desc = 'DESC' +} + +/** Sort Nulls Options */ +export enum SortNulls { + NullsFirst = 'NULLS_FIRST', + NullsLast = 'NULLS_LAST' +} + +export type StringFilter = { + eq?: InputMaybe; + gt?: InputMaybe; + gte?: InputMaybe; + ilike?: InputMaybe; + in?: InputMaybe>; + iregex?: InputMaybe; + is?: InputMaybe; + like?: InputMaybe; + lt?: InputMaybe; + lte?: InputMaybe; + neq?: InputMaybe; + regex?: InputMaybe; + startsWith?: InputMaybe; +}; + +export type Support = { + supportDriver: Scalars['String']; + supportFrontChatId?: Maybe; +}; + +export type Telemetry = { + anonymizationEnabled: Scalars['Boolean']; + enabled: Scalars['Boolean']; +}; + +export type TimelineThread = { + firstParticipant: TimelineThreadParticipant; + id: Scalars['ID']; + lastMessageBody: Scalars['String']; + lastMessageReceivedAt: Scalars['DateTime']; + lastTwoParticipants: Array; + numberOfMessagesInThread: Scalars['Float']; + participantCount: Scalars['Float']; + read: Scalars['Boolean']; + subject: Scalars['String']; + visibility: Scalars['String']; +}; + +export type TimelineThreadParticipant = { + avatarUrl: Scalars['String']; + displayName: Scalars['String']; + firstName: Scalars['String']; + handle: Scalars['String']; + lastName: Scalars['String']; + personId?: Maybe; + workspaceMemberId?: Maybe; +}; + +export type TimelineThreadsWithTotal = { + timelineThreads: Array; + totalNumberOfThreads: Scalars['Int']; +}; + +export type TransientToken = { + transientToken: AuthToken; +}; + +export type UuidFilter = { + eq?: InputMaybe; + in?: InputMaybe>>; + is?: InputMaybe; + neq?: InputMaybe; +}; + +export type UpdateWorkspaceInput = { + allowImpersonation?: InputMaybe; + displayName?: InputMaybe; + domainName?: InputMaybe; + inviteHash?: InputMaybe; + logo?: InputMaybe; +}; + +export type User = { + canImpersonate: Scalars['Boolean']; + createdAt: Scalars['DateTime']; + defaultAvatarUrl?: Maybe; + defaultWorkspace: Workspace; + deletedAt?: Maybe; + disabled?: Maybe; + email: Scalars['String']; + emailVerified: Scalars['Boolean']; + firstName: Scalars['String']; + id: Scalars['ID']; + lastName: Scalars['String']; + passwordHash?: Maybe; + passwordResetToken?: Maybe; + passwordResetTokenExpiresAt?: Maybe; + supportUserHash?: Maybe; + updatedAt: Scalars['DateTime']; + workspaceMember?: Maybe; +}; + +export type UserEdge = { + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the User */ + node: User; +}; + +export type UserExists = { + exists: Scalars['Boolean']; +}; + +export type ValidatePasswordResetToken = { + email: Scalars['String']; + id: Scalars['String']; +}; + +export type Verify = { + tokens: AuthTokenPair; + user: User; +}; + +/** (System) Views */ +export type View = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** Describes if the view is in compact mode */ + isCompact: Scalars['Boolean']; + /** View name */ + name: Scalars['String']; + /** View target object */ + objectMetadataId: Scalars['ID']; + /** View type */ + type: Scalars['String']; + updatedAt: Scalars['DateTime']; + /** View Fields */ + viewFields?: Maybe; + /** View Filters */ + viewFilters?: Maybe; + /** View Sorts */ + viewSorts?: Maybe; +}; + + +/** (System) Views */ +export type ViewViewFieldsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** (System) Views */ +export type ViewViewFiltersArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** (System) Views */ +export type ViewViewSortsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** (System) Views */ +export type ViewConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** (System) Views */ +export type ViewCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Describes if the view is in compact mode */ + isCompact?: InputMaybe; + /** View name */ + name?: InputMaybe; + /** View target object */ + objectMetadataId: Scalars['ID']; + /** View type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** (System) Views */ +export type ViewEdge = { + cursor: Scalars['Cursor']; + node: View; +}; + +/** (System) View Fields */ +export type ViewField = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** View Field target field */ + fieldMetadataId: Scalars['ID']; + id: Scalars['ID']; + /** View Field visibility */ + isVisible: Scalars['Boolean']; + /** View Field position */ + position: Scalars['Float']; + /** View Field size */ + size: Scalars['Float']; + updatedAt: Scalars['DateTime']; + /** View Field related view */ + view?: Maybe; + /** View Field related view id foreign key */ + viewId?: Maybe; +}; + +/** (System) View Fields */ +export type ViewFieldConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** (System) View Fields */ +export type ViewFieldCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Field target field */ + fieldMetadataId: Scalars['ID']; + id?: InputMaybe; + /** View Field visibility */ + isVisible?: InputMaybe; + /** View Field position */ + position?: InputMaybe; + /** View Field size */ + size?: InputMaybe; + updatedAt?: InputMaybe; + /** View Field related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Fields */ +export type ViewFieldEdge = { + cursor: Scalars['Cursor']; + node: ViewField; +}; + +/** (System) View Fields */ +export type ViewFieldFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Field target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + /** View Field visibility */ + isVisible?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + /** View Field position */ + position?: InputMaybe; + /** View Field size */ + size?: InputMaybe; + updatedAt?: InputMaybe; + /** View Field related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Fields */ +export type ViewFieldOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Field target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + /** View Field visibility */ + isVisible?: InputMaybe; + /** View Field position */ + position?: InputMaybe; + /** View Field size */ + size?: InputMaybe; + updatedAt?: InputMaybe; + /** View Field related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Fields */ +export type ViewFieldUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Field target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + /** View Field visibility */ + isVisible?: InputMaybe; + /** View Field position */ + position?: InputMaybe; + /** View Field size */ + size?: InputMaybe; + updatedAt?: InputMaybe; + /** View Field related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Filters */ +export type ViewFilter = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** View Filter Display Value */ + displayValue: Scalars['String']; + /** View Filter target field */ + fieldMetadataId: Scalars['ID']; + id: Scalars['ID']; + /** View Filter operand */ + operand: Scalars['String']; + updatedAt: Scalars['DateTime']; + /** View Filter value */ + value: Scalars['String']; + /** View Filter related view */ + view?: Maybe; + /** View Filter related view id foreign key */ + viewId?: Maybe; +}; + +/** (System) View Filters */ +export type ViewFilterConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** (System) View Filters */ +export type ViewFilterCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Filter Display Value */ + displayValue?: InputMaybe; + /** View Filter target field */ + fieldMetadataId: Scalars['ID']; + id?: InputMaybe; + /** View Filter operand */ + operand?: InputMaybe; + updatedAt?: InputMaybe; + /** View Filter value */ + value?: InputMaybe; + /** View Filter related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Filters */ +export type ViewFilterEdge = { + cursor: Scalars['Cursor']; + node: ViewFilter; +}; + +/** (System) View Filters */ +export type ViewFilterFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Filter Display Value */ + displayValue?: InputMaybe; + /** View Filter target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + /** View Filter operand */ + operand?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; + /** View Filter value */ + value?: InputMaybe; + /** View Filter related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) Views */ +export type ViewFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Describes if the view is in compact mode */ + isCompact?: InputMaybe; + /** View name */ + name?: InputMaybe; + not?: InputMaybe; + /** View target object */ + objectMetadataId?: InputMaybe; + or?: InputMaybe>>; + /** View type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** (System) View Filters */ +export type ViewFilterOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Filter Display Value */ + displayValue?: InputMaybe; + /** View Filter target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + /** View Filter operand */ + operand?: InputMaybe; + updatedAt?: InputMaybe; + /** View Filter value */ + value?: InputMaybe; + /** View Filter related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Filters */ +export type ViewFilterUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Filter Display Value */ + displayValue?: InputMaybe; + /** View Filter target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + /** View Filter operand */ + operand?: InputMaybe; + updatedAt?: InputMaybe; + /** View Filter value */ + value?: InputMaybe; + /** View Filter related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) Views */ +export type ViewOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Describes if the view is in compact mode */ + isCompact?: InputMaybe; + /** View name */ + name?: InputMaybe; + /** View target object */ + objectMetadataId?: InputMaybe; + /** View type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** (System) View Sorts */ +export type ViewSort = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** View Sort direction */ + direction: Scalars['String']; + /** View Sort target field */ + fieldMetadataId: Scalars['ID']; + id: Scalars['ID']; + updatedAt: Scalars['DateTime']; + /** View Sort related view */ + view?: Maybe; + /** View Sort related view id foreign key */ + viewId?: Maybe; +}; + +/** (System) View Sorts */ +export type ViewSortConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** (System) View Sorts */ +export type ViewSortCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Sort direction */ + direction?: InputMaybe; + /** View Sort target field */ + fieldMetadataId: Scalars['ID']; + id?: InputMaybe; + updatedAt?: InputMaybe; + /** View Sort related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Sorts */ +export type ViewSortEdge = { + cursor: Scalars['Cursor']; + node: ViewSort; +}; + +/** (System) View Sorts */ +export type ViewSortFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Sort direction */ + direction?: InputMaybe; + /** View Sort target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; + /** View Sort related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Sorts */ +export type ViewSortOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Sort direction */ + direction?: InputMaybe; + /** View Sort target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; + /** View Sort related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) View Sorts */ +export type ViewSortUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + /** View Sort direction */ + direction?: InputMaybe; + /** View Sort target field */ + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + updatedAt?: InputMaybe; + /** View Sort related view id foreign key */ + viewId?: InputMaybe; +}; + +/** (System) Views */ +export type ViewUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Describes if the view is in compact mode */ + isCompact?: InputMaybe; + /** View name */ + name?: InputMaybe; + /** View target object */ + objectMetadataId?: InputMaybe; + /** View type */ + type?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A webhook */ +export type Webhook = { + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + id: Scalars['ID']; + /** Webhook operation */ + operation: Scalars['String']; + /** Webhook target url */ + targetUrl: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +/** A webhook */ +export type WebhookConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A webhook */ +export type WebhookCreateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Webhook operation */ + operation?: InputMaybe; + /** Webhook target url */ + targetUrl?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A webhook */ +export type WebhookEdge = { + cursor: Scalars['Cursor']; + node: Webhook; +}; + +/** A webhook */ +export type WebhookFilterInput = { + and?: InputMaybe>>; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + not?: InputMaybe; + /** Webhook operation */ + operation?: InputMaybe; + or?: InputMaybe>>; + /** Webhook target url */ + targetUrl?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A webhook */ +export type WebhookOrderByInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Webhook operation */ + operation?: InputMaybe; + /** Webhook target url */ + targetUrl?: InputMaybe; + updatedAt?: InputMaybe; +}; + +/** A webhook */ +export type WebhookUpdateInput = { + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Webhook operation */ + operation?: InputMaybe; + /** Webhook target url */ + targetUrl?: InputMaybe; + updatedAt?: InputMaybe; +}; + +export type Workspace = { + activationStatus: Scalars['String']; + allowImpersonation: Scalars['Boolean']; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + displayName?: Maybe; + domainName?: Maybe; + featureFlags?: Maybe>; + id: Scalars['ID']; + inviteHash?: Maybe; + logo?: Maybe; + subscriptionStatus: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + + +export type WorkspaceFeatureFlagsArgs = { + filter?: FeatureFlagFilter; + sorting?: Array; +}; + +export type WorkspaceEdge = { + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the Workspace */ + node: Workspace; +}; + +export type WorkspaceInviteHashValid = { + isValid: Scalars['Boolean']; +}; + +/** A workspace member */ +export type WorkspaceMember = { + /** Account owner for companies */ + accountOwnerForCompanies?: Maybe; + /** Activities assigned to the workspace member */ + assignedActivities?: Maybe; + /** Activities created by the workspace member */ + authoredActivities?: Maybe; + /** Attachments created by the workspace member */ + authoredAttachments?: Maybe; + /** Authored comments */ + authoredComments?: Maybe; + /** Workspace member avatar */ + avatarUrl: Scalars['String']; + /** Blocklisted handles */ + blocklist?: Maybe; + /** Preferred color scheme */ + colorScheme: Scalars['String']; + /** Connected accounts */ + connectedAccounts?: Maybe; + createdAt: Scalars['DateTime']; + deletedAt?: Maybe; + /** Favorites linked to the workspace member */ + favorites?: Maybe; + id: Scalars['ID']; + /** Preferred language */ + locale: Scalars['String']; + /** Message Participants */ + messageParticipants?: Maybe; + /** Workspace member name */ + name: FullName; + updatedAt: Scalars['DateTime']; + /** Related user email address */ + userEmail: Scalars['String']; + /** Associated User Id */ + userId: Scalars['ID']; +}; + + +/** A workspace member */ +export type WorkspaceMemberAccountOwnerForCompaniesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberAssignedActivitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberAuthoredActivitiesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberAuthoredAttachmentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberAuthoredCommentsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberBlocklistArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberConnectedAccountsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberFavoritesArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + + +/** A workspace member */ +export type WorkspaceMemberMessageParticipantsArgs = { + after?: InputMaybe; + before?: InputMaybe; + filter?: InputMaybe; + first?: InputMaybe; + last?: InputMaybe; + orderBy?: InputMaybe; +}; + +/** A workspace member */ +export type WorkspaceMemberConnection = { + edges: Array; + pageInfo: PageInfo; + /** Total number of records in the connection */ + totalCount?: Maybe; +}; + +/** A workspace member */ +export type WorkspaceMemberCreateInput = { + /** Workspace member avatar */ + avatarUrl?: InputMaybe; + /** Preferred color scheme */ + colorScheme?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Preferred language */ + locale?: InputMaybe; + /** Workspace member name */ + name?: InputMaybe; + updatedAt?: InputMaybe; + /** Related user email address */ + userEmail?: InputMaybe; + /** Associated User Id */ + userId: Scalars['ID']; +}; + +/** A workspace member */ +export type WorkspaceMemberEdge = { + cursor: Scalars['Cursor']; + node: WorkspaceMember; +}; + +/** A workspace member */ +export type WorkspaceMemberFilterInput = { + and?: InputMaybe>>; + /** Workspace member avatar */ + avatarUrl?: InputMaybe; + /** Preferred color scheme */ + colorScheme?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Preferred language */ + locale?: InputMaybe; + /** Workspace member name */ + name?: InputMaybe; + not?: InputMaybe; + or?: InputMaybe>>; + updatedAt?: InputMaybe; + /** Related user email address */ + userEmail?: InputMaybe; + /** Associated User Id */ + userId?: InputMaybe; +}; + +/** A workspace member */ +export type WorkspaceMemberOrderByInput = { + /** Workspace member avatar */ + avatarUrl?: InputMaybe; + /** Preferred color scheme */ + colorScheme?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Preferred language */ + locale?: InputMaybe; + /** Workspace member name */ + name?: InputMaybe; + updatedAt?: InputMaybe; + /** Related user email address */ + userEmail?: InputMaybe; + /** Associated User Id */ + userId?: InputMaybe; +}; + +/** A workspace member */ +export type WorkspaceMemberUpdateInput = { + /** Workspace member avatar */ + avatarUrl?: InputMaybe; + /** Preferred color scheme */ + colorScheme?: InputMaybe; + createdAt?: InputMaybe; + deletedAt?: InputMaybe; + id?: InputMaybe; + /** Preferred language */ + locale?: InputMaybe; + /** Workspace member name */ + name?: InputMaybe; + updatedAt?: InputMaybe; + /** Related user email address */ + userEmail?: InputMaybe; + /** Associated User Id */ + userId?: InputMaybe; +}; + +export type Field = { + createdAt: Scalars['DateTime']; + defaultValue?: Maybe; + description?: Maybe; + fromRelationMetadata?: Maybe; + icon?: Maybe; + id: Scalars['ID']; + isActive?: Maybe; + isCustom?: Maybe; + isNullable?: Maybe; + isSystem?: Maybe; + label: Scalars['String']; + name: Scalars['String']; + options?: Maybe; + toRelationMetadata?: Maybe; + type: FieldMetadataType; + updatedAt: Scalars['DateTime']; +}; + +export type FieldEdge = { + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the field */ + node: Field; +}; + +export type FieldFilter = { + and?: InputMaybe>; + id?: InputMaybe; + isActive?: InputMaybe; + isCustom?: InputMaybe; + isSystem?: InputMaybe; + or?: InputMaybe>; +}; + +export type Object = { + createdAt: Scalars['DateTime']; + dataSourceId: Scalars['String']; + description?: Maybe; + fields: ObjectFieldsConnection; + icon?: Maybe; + id: Scalars['ID']; + imageIdentifierFieldMetadataId?: Maybe; + isActive: Scalars['Boolean']; + isCustom: Scalars['Boolean']; + isSystem: Scalars['Boolean']; + labelIdentifierFieldMetadataId?: Maybe; + labelPlural: Scalars['String']; + labelSingular: Scalars['String']; + namePlural: Scalars['String']; + nameSingular: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + + +export type ObjectFieldsArgs = { + filter?: FieldFilter; + paging?: CursorPaging; +}; + +export type ObjectEdge = { + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the object */ + node: Object; +}; + +export type Relation = { + createdAt: Scalars['DateTime']; + fromFieldMetadataId: Scalars['String']; + fromObjectMetadata: Object; + fromObjectMetadataId: Scalars['String']; + id: Scalars['ID']; + relationType: RelationMetadataType; + toFieldMetadataId: Scalars['String']; + toObjectMetadata: Object; + toObjectMetadataId: Scalars['String']; + updatedAt: Scalars['DateTime']; +}; + +export type RelationEdge = { + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the relation */ + node: Relation; +}; + +export type FindCompanyQueryVariables = Exact<{ + filter: CompanyFilterInput; +}>; + + +export type FindCompanyQuery = { companies: { edges: Array<{ node: { linkedinLink?: { url?: string | null, label?: string | null } | null } }> } }; + + +export const FindCompanyDocument = gql` + query FindCompany($filter: CompanyFilterInput!) { + companies(filter: $filter) { + edges { + node { + linkedinLink { + url + label + } + } + } + } +} + `; + +/** + * __useFindCompanyQuery__ + * + * To run a query within a React component, call `useFindCompanyQuery` and pass it any options that fit your needs. + * When your component renders, `useFindCompanyQuery` 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 } = useFindCompanyQuery({ + * variables: { + * filter: // value for 'filter' + * }, + * }); + */ +export function useFindCompanyQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(FindCompanyDocument, options); + } +export function useFindCompanyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(FindCompanyDocument, options); + } +export type FindCompanyQueryHookResult = ReturnType; +export type FindCompanyLazyQueryHookResult = ReturnType; +export type FindCompanyQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/twenty-chrome-extension/src/graphql/company/mutations.ts b/packages/twenty-chrome-extension/src/graphql/company/mutations.ts new file mode 100644 index 000000000000..36bfabf401a3 --- /dev/null +++ b/packages/twenty-chrome-extension/src/graphql/company/mutations.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const CREATE_COMPANY = gql` + mutation CreateOneCompany($input: CompanyCreateInput!) { + createCompany(data: $input) { + id + } + } +`; diff --git a/packages/twenty-chrome-extension/src/graphql/company/queries.ts b/packages/twenty-chrome-extension/src/graphql/company/queries.ts new file mode 100644 index 000000000000..b3fceedb29db --- /dev/null +++ b/packages/twenty-chrome-extension/src/graphql/company/queries.ts @@ -0,0 +1,17 @@ +import { gql } from '@apollo/client'; + +export const FIND_COMPANY = gql` + query FindCompany($filter: CompanyFilterInput!) { + companies(filter: $filter) { + edges { + node { + name + linkedinLink { + url + label + } + } + } + } + } +`; diff --git a/packages/twenty-chrome-extension/src/graphql/person/mutations.ts b/packages/twenty-chrome-extension/src/graphql/person/mutations.ts new file mode 100644 index 000000000000..947d3a4e0cd4 --- /dev/null +++ b/packages/twenty-chrome-extension/src/graphql/person/mutations.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const CREATE_PERSON = gql` + mutation CreateOnePerson($input: PersonCreateInput!) { + createPerson(data: $input) { + id + } + } +`; diff --git a/packages/twenty-chrome-extension/src/graphql/person/queries.ts b/packages/twenty-chrome-extension/src/graphql/person/queries.ts new file mode 100644 index 000000000000..83f402c1c280 --- /dev/null +++ b/packages/twenty-chrome-extension/src/graphql/person/queries.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +export const FIND_PERSON = gql` + query FindPerson($filter: PersonFilterInput!) { + people(filter: $filter) { + edges { + node { + name { + firstName + lastName + } + linkedinLink { + url + label + } + } + } + } + } +`; diff --git a/packages/twenty-chrome-extension/src/manifest.ts b/packages/twenty-chrome-extension/src/manifest.ts index dc08d0bca71f..dd3896ba7823 100644 --- a/packages/twenty-chrome-extension/src/manifest.ts +++ b/packages/twenty-chrome-extension/src/manifest.ts @@ -16,6 +16,7 @@ export default defineManifest({ action: {}, + //TODO: change this to a documenation page options_page: 'options.html', background: { @@ -34,4 +35,8 @@ export default defineManifest({ permissions: ['activeTab', 'storage'], host_permissions: ['https://www.linkedin.com/*'], + + externally_connectable: { + matches: [`https://app.twenty.com/*`, `http://localhost:3001/*`], + }, }); diff --git a/packages/twenty-chrome-extension/src/options/Loading.tsx b/packages/twenty-chrome-extension/src/options/Loading.tsx new file mode 100644 index 000000000000..1fde24f2b9e5 --- /dev/null +++ b/packages/twenty-chrome-extension/src/options/Loading.tsx @@ -0,0 +1,24 @@ +import styled from '@emotion/styled'; + +import { Loader } from '@/ui/display/loader/components/Loader'; + +const StyledContainer = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.noisy}; + display: flex; + flex-direction: column; + height: 100vh; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: center; +`; + +const Loading = () => { + return ( + + twenty-logo + + + ); +}; + +export default Loading; diff --git a/packages/twenty-chrome-extension/src/options/index.tsx b/packages/twenty-chrome-extension/src/options/index.tsx index dde380ebf90d..e7920fc9e61a 100644 --- a/packages/twenty-chrome-extension/src/options/index.tsx +++ b/packages/twenty-chrome-extension/src/options/index.tsx @@ -3,14 +3,14 @@ import ReactDOM from 'react-dom/client'; import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; import { ThemeType } from '@/ui/theme/constants/ThemeLight'; -import App from '~/options/Options'; +import Options from '~/options/Options'; import '~/index.css'; ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render( - + , ); diff --git a/packages/twenty-chrome-extension/src/options/loading-index.tsx b/packages/twenty-chrome-extension/src/options/loading-index.tsx new file mode 100644 index 000000000000..46f2193fe042 --- /dev/null +++ b/packages/twenty-chrome-extension/src/options/loading-index.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; +import { ThemeType } from '@/ui/theme/constants/ThemeLight'; +import Loading from '~/options/Loading'; + +import '~/index.css'; + +ReactDOM.createRoot(document.getElementById('app') as HTMLElement).render( + + + + + , +); + +declare module '@emotion/react' { + export interface Theme extends ThemeType {} +} diff --git a/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx b/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx index 88e051972fe4..524dc0c1faa2 100644 --- a/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx +++ b/packages/twenty-chrome-extension/src/options/modules/api-key/components/ApiKeyForm.tsx @@ -23,7 +23,11 @@ const StyledHeader = styled.header` text-align: center; `; -const StyledImg = styled.img``; +const StyledImgLogo = styled.img` + &:hover { + cursor: pointer; + } +`; const StyledMain = styled.main` margin-bottom: ${({ theme }) => theme.spacing(8)}; @@ -51,6 +55,13 @@ const StyledSection = styled.div<{ showSection: boolean }>` max-height: ${({ showSection }) => (showSection ? '200px' : '0')}; `; +const StyledButtonHorizontalContainer = styled.div` + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(4)}; + width: 100%; +`; + export const ApiKeyForm = () => { const [apiKey, setApiKey] = useState(''); const [route, setRoute] = useState(''); @@ -73,10 +84,6 @@ export const ApiKeyForm = () => { void getState(); }, []); - useEffect(() => { - chrome.storage.local.set({ apiKey }); - }, [apiKey]); - useEffect(() => { if (import.meta.env.VITE_SERVER_BASE_URL !== route) { chrome.storage.local.set({ serverBaseUrl: route }); @@ -85,10 +92,18 @@ export const ApiKeyForm = () => { } }, [route]); + const handleValidateKey = () => { + chrome.storage.local.set({ apiKey }); + + window.close(); + }; + const handleGenerateClick = () => { - window.open( - `${import.meta.env.VITE_FRONT_BASE_URL}/settings/developers/api-keys`, - ); + window.open(`${import.meta.env.VITE_FRONT_BASE_URL}/settings/developers`); + }; + + const handleGoToTwenty = () => { + window.open(`${import.meta.env.VITE_FRONT_BASE_URL}`); }; const handleToggle = () => { @@ -98,9 +113,12 @@ export const ApiKeyForm = () => { return ( - + - { onChange={setApiKey} placeholder="My API key" /> - - - - ); -}; + if (isNonEmptyString(block.props.url)) { + return ( + + + + + {block.props.name} + + + + ); + } -export const FileBlock = createReactBlockSpec(FileBlockConfig, { - render: (block) => { - return ( - - ); + return ( + + + + + + + ); + }, }, -}); +); diff --git a/packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts b/packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts deleted file mode 100644 index fd95c52f241c..000000000000 --- a/packages/twenty-front/src/modules/activities/blocks/blockSpecs.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defaultBlockSpecs } from '@blocknote/core'; - -import { FileBlock } from './FileBlock'; - -export const blockSpecs: any = { - ...defaultBlockSpecs, - file: FileBlock, -}; diff --git a/packages/twenty-front/src/modules/activities/blocks/schema.ts b/packages/twenty-front/src/modules/activities/blocks/schema.ts index 78e83e7d20d6..d6ea82eac19b 100644 --- a/packages/twenty-front/src/modules/activities/blocks/schema.ts +++ b/packages/twenty-front/src/modules/activities/blocks/schema.ts @@ -1,8 +1,10 @@ -import { BlockSchema, defaultBlockSchema } from '@blocknote/core'; +import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'; import { FileBlock } from './FileBlock'; -export const blockSchema: BlockSchema = { - ...defaultBlockSchema, - file: FileBlock.config, -}; +export const blockSchema = 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/slashMenu.tsx index 437d61f3fdcf..8db3376e8163 100644 --- a/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx +++ b/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx @@ -1,30 +1,45 @@ -import { - BlockNoteEditor, - InlineContentSchema, - StyleSchema, -} from '@blocknote/core'; import { getDefaultReactSlashMenuItems } from '@blocknote/react'; +import { + IconFile, + IconH1, + IconH2, + IconH3, + IconList, + IconListNumbers, + IconPhoto, + IconPilcrow, + IconTable, +} from 'twenty-ui'; -import { IconFile } from '@/ui/display/icon'; +import { IconComponent } from '@/ui/display/icon/types/IconComponent'; +import { SuggestionItem } from '@/ui/input/editor/components/CustomSlashMenu'; import { blockSchema } from './schema'; -export const getSlashMenu = () => { - const items = [ - ...getDefaultReactSlashMenuItems(blockSchema), +const Icons: Record = { + 'Heading 1': IconH1, + 'Heading 2': IconH2, + 'Heading 3': IconH3, + 'Numbered List': IconListNumbers, + 'Bullet List': IconList, + Paragraph: IconPilcrow, + Table: IconTable, + Image: IconPhoto, +}; + +export const getSlashMenu = (editor: typeof blockSchema.BlockNoteEditor) => { + const items: SuggestionItem[] = [ + ...getDefaultReactSlashMenuItems(editor).map((x) => ({ + ...x, + Icon: Icons[x.title], + })), { - name: 'File', + title: 'File', aliases: ['file', 'folder'], - group: 'Media', - icon: , - hint: 'Insert a file', - execute: ( - editor: BlockNoteEditor< - typeof blockSchema, - InlineContentSchema, - StyleSchema - >, - ) => { + Icon: IconFile, + onItemClick: () => { + const currentBlock = editor.getTextCursorPosition().block; + editor.insertBlocks( [ { @@ -34,7 +49,7 @@ export const getSlashMenu = () => { }, }, ], - editor.getTextCursorPosition().block, + currentBlock, 'before', ); }, diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx new file mode 100644 index 000000000000..11a41c8c2505 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -0,0 +1,138 @@ +import styled from '@emotion/styled'; +import { format, getYear } from 'date-fns'; + +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 { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; +import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId'; +import { getTimelineCalendarEventsFromPersonId } from '@/activities/calendar/queries/getTimelineCalendarEventsFromPersonId'; +import { FetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; +import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { H3Title } from '@/ui/display/typography/components/H3Title'; +import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; +import { + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, +} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; +import { Section } from '@/ui/layout/section/components/Section'; +import { TimelineCalendarEventsWithTotal } from '~/generated/graphql'; + +const StyledContainer = styled.div` + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(8)}; + padding: ${({ theme }) => theme.spacing(6)}; + width: 100%; + overflow: scroll; +`; + +const StyledYear = styled.span` + color: ${({ theme }) => theme.font.color.light}; +`; + +export const Calendar = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const [query, queryName] = + targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person + ? [ + getTimelineCalendarEventsFromPersonId, + 'getTimelineCalendarEventsFromPersonId', + ] + : [ + getTimelineCalendarEventsFromCompanyId, + 'getTimelineCalendarEventsFromCompanyId', + ]; + + const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords } = + useCustomResolver( + query, + queryName, + 'timelineCalendarEvents', + targetableObject, + TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE, + ); + + const { timelineCalendarEvents } = data?.[queryName] ?? {}; + + const { + calendarEventsByDayTime, + currentCalendarEvent, + daysByMonthTime, + getNextCalendarEvent, + monthTimes, + monthTimesByYear, + updateCurrentCalendarEvent, + } = useCalendarEvents(timelineCalendarEvents || []); + + if (firstQueryLoading) { + // TODO: implement loader + return; + } + + if (!firstQueryLoading && !timelineCalendarEvents?.length) { + // TODO: change animated placeholder + return ( + + + + + No Events + + + No events have been scheduled with this{' '} + {targetableObject.targetObjectNameSingular} yet. + + + + ); + } + + return ( + + + {monthTimes.map((monthTime) => { + const monthDayTimes = daysByMonthTime[monthTime] || []; + const year = getYear(monthTime); + const lastMonthTimeOfYear = monthTimesByYear[year]?.[0]; + const isLastMonthOfYear = lastMonthTimeOfYear === monthTime; + const monthLabel = format(monthTime, 'MMMM'); + + return ( +
+ + {monthLabel} + {isLastMonthOfYear && {year}} + + } + /> + +
+ ); + })} + +
+
+ ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx new file mode 100644 index 000000000000..02d11fa87e7a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarCurrentEventCursor.tsx @@ -0,0 +1,173 @@ +import { useContext, useMemo, useState } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { + differenceInSeconds, + isThisMonth, + startOfDay, + startOfMonth, +} from 'date-fns'; +import { AnimatePresence, motion } from 'framer-motion'; + +import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; +import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; +import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; +import { hasCalendarEventStarted } from '@/activities/calendar/utils/hasCalendarEventStarted'; +import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; + +type CalendarCurrentEventCursorProps = { + calendarEvent: TimelineCalendarEvent; +}; + +const StyledCurrentEventCursor = styled(motion.div)` + align-items: center; + background-color: ${({ theme }) => theme.font.color.danger}; + display: inline-flex; + height: 1.5px; + left: 0; + position: absolute; + right: 0; + border-radius: ${({ theme }) => theme.border.radius.sm}; + transform: translateY(-50%); + + &::before { + background-color: ${({ theme }) => theme.font.color.danger}; + border-radius: 1px; + content: ''; + display: block; + height: ${({ theme }) => theme.spacing(1)}; + width: ${({ theme }) => theme.spacing(1)}; + } +`; + +export const CalendarCurrentEventCursor = ({ + calendarEvent, +}: CalendarCurrentEventCursorProps) => { + const theme = useTheme(); + const { + calendarEventsByDayTime, + currentCalendarEvent, + getNextCalendarEvent, + updateCurrentCalendarEvent, + } = useContext(CalendarContext); + + const nextCalendarEvent = getNextCalendarEvent(calendarEvent); + const nextCalendarEventStartsAt = nextCalendarEvent + ? getCalendarEventStartDate(nextCalendarEvent) + : undefined; + const isNextEventThisMonth = + !!nextCalendarEventStartsAt && isThisMonth(nextCalendarEventStartsAt); + + const calendarEventStartsAt = getCalendarEventStartDate(calendarEvent); + const calendarEventEndsAt = getCalendarEventEndDate(calendarEvent); + + const isCurrent = currentCalendarEvent?.id === calendarEvent.id; + const [hasStarted, setHasStarted] = useState( + hasCalendarEventStarted(calendarEvent), + ); + const [hasEnded, setHasEnded] = useState( + hasCalendarEventEnded(calendarEvent), + ); + const [isWaiting, setIsWaiting] = useState(hasEnded && !isNextEventThisMonth); + + const dayTime = startOfDay(calendarEventStartsAt).getTime(); + const dayEvents = calendarEventsByDayTime[dayTime]; + const isFirstEventOfDay = dayEvents?.slice(-1)[0] === calendarEvent; + const isLastEventOfDay = dayEvents?.[0] === calendarEvent; + + const topOffset = isLastEventOfDay ? 9 : 6; + const bottomOffset = isFirstEventOfDay ? 9 : 6; + + const currentEventCursorVariants = { + beforeEvent: { top: `calc(100% + ${bottomOffset}px)` }, + eventStart: { + top: 'calc(100% + 3px)', + transition: { + delay: Math.max( + 0, + differenceInSeconds(calendarEventStartsAt, new Date()), + ), + }, + }, + eventEnd: { + top: `-${topOffset}px`, + transition: { + delay: Math.max( + 0, + differenceInSeconds(calendarEventEndsAt, new Date()) + 1, + ), + }, + }, + fadeAway: { + opacity: 0, + top: `-${topOffset}px`, + transition: { + delay: + isWaiting && nextCalendarEventStartsAt + ? differenceInSeconds( + startOfMonth(nextCalendarEventStartsAt), + new Date(), + ) + : 0, + }, + }, + }; + + const animationSequence = useMemo(() => { + if (!hasStarted) return { initial: 'beforeEvent', animate: 'eventStart' }; + + if (!hasEnded) { + return { initial: 'eventStart', animate: 'eventEnd' }; + } + + if (!isWaiting) { + return { initial: undefined, animate: 'eventEnd' }; + } + + return { initial: 'eventEnd', animate: 'fadeAway' }; + }, [hasEnded, hasStarted, isWaiting]); + + return ( + + {isCurrent && ( + { + if (stage === 'eventStart') { + setHasStarted(true); + } + + if (stage === 'eventEnd') { + setHasEnded(true); + + if (isNextEventThisMonth) { + updateCurrentCalendarEvent(); + } + // If the next event is not the same month as the current event, + // we don't want the cursor to jump to the next month until the next month starts. + // => Wait for the upcoming event's month to start before moving the cursor. + // Example: we're in March. The previous event is February 15th, and the next event is April 10th. + // We want the cursor to stay in February until April starts. + else { + setIsWaiting(true); + } + } + + if (isWaiting && stage === 'fadeAway') { + setIsWaiting(false); + updateCurrentCalendarEvent(); + } + }} + transition={{ + duration: theme.animation.duration.normal, + }} + /> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx new file mode 100644 index 000000000000..c4ff26571109 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx @@ -0,0 +1,93 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +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 { TimelineCalendarEvent } from '~/generated-metadata/graphql'; + +type CalendarDayCardContentProps = { + calendarEvents: TimelineCalendarEvent[]; + divider?: boolean; +}; + +const StyledCardContent = styled(CardContent)` + align-items: flex-start; + border-color: ${({ theme }) => theme.border.color.light}; + display: flex; + flex-direction: row; + gap: ${({ theme }) => theme.spacing(3)}; + padding: ${({ theme }) => theme.spacing(2, 3)}; +`; + +const StyledDayContainer = styled.div` + text-align: center; + width: ${({ theme }) => theme.spacing(6)}; +`; + +const StyledWeekDay = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.xxs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; +`; + +const StyledMonthDay = styled.div` + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledEvents = styled.div` + align-items: stretch; + display: flex; + flex: 1 0 auto; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; +`; + +const StyledEventRow = styled(CalendarEventRow)` + flex: 1 0 auto; +`; + +export const CalendarDayCardContent = ({ + calendarEvents, + divider, +}: CalendarDayCardContentProps) => { + const theme = useTheme(); + + const endOfDayDate = endOfDay(getCalendarEventStartDate(calendarEvents[0])); + const dayEndsIn = differenceInSeconds(endOfDayDate, Date.now()); + + const weekDayLabel = format(endOfDayDate, 'EE'); + const monthDayLabel = format(endOfDayDate, 'dd'); + + const upcomingDayCardContentVariants = { + upcoming: {}, + ended: { backgroundColor: theme.background.primary }, + }; + + return ( + + + {weekDayLabel} + {monthDayLabel} + + + {calendarEvents.map((calendarEvent) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx new file mode 100644 index 000000000000..363ceafeb586 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventDetails.tsx @@ -0,0 +1,154 @@ +import React from 'react'; +import { css, useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconCalendarEvent } from 'twenty-ui'; + +import { CalendarEventParticipantsResponseStatus } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatus'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; +import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; +import { + Chip, + ChipAccent, + ChipSize, + ChipVariant, +} from '@/ui/display/chip/components/Chip'; +import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; +import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; + +type CalendarEventDetailsProps = { + calendarEvent: CalendarEvent; +}; + +const StyledContainer = styled.div` + background: ${({ theme }) => theme.background.secondary}; + align-items: flex-start; + border-bottom: 1px solid ${({ theme }) => theme.border.color.medium}; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(6)}; + padding: ${({ theme }) => theme.spacing(6)}; + width: 100%; + box-sizing: border-box; +`; + +const StyledEventChip = styled(Chip)` + gap: ${({ theme }) => theme.spacing(2)}; + padding-left: ${({ theme }) => theme.spacing(2)}; + padding-right: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledHeader = styled.header``; + +const StyledTitle = styled.h2<{ canceled?: boolean }>` + color: ${({ theme }) => theme.font.color.primary}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin: ${({ theme }) => theme.spacing(0, 0, 2)}; + + ${({ canceled }) => + canceled && + css` + text-decoration: line-through; + `} +`; + +const StyledCreatedAt = styled.div` + color: ${({ theme }) => theme.font.color.tertiary}; +`; + +const StyledFields = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(3)}; + width: 100%; +`; + +const StyledPropertyBox = styled(PropertyBox)` + height: ${({ theme }) => theme.spacing(6)}; + padding: 0; + width: 100%; +`; + +export const CalendarEventDetails = ({ + calendarEvent, +}: CalendarEventDetailsProps) => { + const theme = useTheme(); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.CalendarEvent, + }); + + const fieldsToDisplay = [ + 'startsAt', + 'endsAt', + 'conferenceLink', + 'location', + 'description', + ]; + + const fieldsByName = mapArrayToObject( + objectMetadataItem.fields, + ({ name }) => name, + ); + + const { calendarEventParticipants } = calendarEvent; + + const Fields = fieldsToDisplay.map((fieldName) => ( + + [() => undefined, { loading: false }], + maxWidth: 300, + }} + > + + + + )); + + return ( + + } + label="Event" + /> + + + {calendarEvent.title} + + + Created{' '} + {beautifyPastDateRelativeToNow( + new Date(calendarEvent.externalCreatedAt), + )} + + + + {Fields.slice(0, 2)} + {calendarEventParticipants && ( + + )} + {Fields.slice(2)} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatus.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatus.tsx new file mode 100644 index 000000000000..1db72c28ac34 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatus.tsx @@ -0,0 +1,42 @@ +import groupBy from 'lodash.groupby'; + +import { CalendarEventParticipantsResponseStatusField } from '@/activities/calendar/components/CalendarEventParticipantsResponseStatusField'; +import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant'; + +export const CalendarEventParticipantsResponseStatus = ({ + participants, +}: { + participants: CalendarEventParticipant[]; +}) => { + const groupedParticipants = groupBy(participants, (participant) => { + switch (participant.responseStatus) { + case 'ACCEPTED': + return 'Yes'; + case 'DECLINED': + return 'No'; + case 'NEEDS_ACTION': + case 'TENTATIVE': + return 'Maybe'; + default: + return ''; + } + }); + + const responseStatusOrder: ('Yes' | 'Maybe' | 'No')[] = [ + 'Yes', + 'Maybe', + 'No', + ]; + + return ( + <> + {responseStatusOrder.map((responseStatus) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx new file mode 100644 index 000000000000..6a83a6f723b5 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventParticipantsResponseStatusField.tsx @@ -0,0 +1,108 @@ +import { useRef } from 'react'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconCheck, IconQuestionMark, IconX } from 'twenty-ui'; +import { v4 } from 'uuid'; + +import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant'; +import { ParticipantChip } from '@/activities/components/ParticipantChip'; +import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; +import { ExpandableList } from '@/ui/display/expandable-list/ExpandableList'; +import { EllipsisDisplay } from '@/ui/field/display/components/EllipsisDisplay'; + +const StyledInlineCellBaseContainer = styled.div` + align-items: center; + box-sizing: border-box; + width: 100%; + display: flex; + + gap: ${({ theme }) => theme.spacing(1)}; + + position: relative; + user-select: none; +`; + +const StyledPropertyBox = styled(PropertyBox)` + height: ${({ theme }) => theme.spacing(6)}; + padding: 0; + width: 100%; +`; + +const StyledIconContainer = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + width: 16px; + + svg { + align-items: center; + display: flex; + height: 16px; + justify-content: center; + width: 16px; + } +`; + +const StyledLabelAndIconContainer = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledLabelContainer = styled.div<{ width?: number }>` + color: ${({ theme }) => theme.font.color.tertiary}; + font-size: ${({ theme }) => theme.font.size.sm}; + width: ${({ width }) => width}px; +`; + +export const CalendarEventParticipantsResponseStatusField = ({ + responseStatus, + participants, +}: { + responseStatus: 'Yes' | 'Maybe' | 'No'; + participants: CalendarEventParticipant[]; +}) => { + const theme = useTheme(); + + const Icon = { + Yes: , + Maybe: , + No: , + }[responseStatus]; + + // We want to display external participants first + const orderedParticipants = [ + ...participants.filter((participant) => participant.person), + ...participants.filter( + (participant) => !participant.person && !participant.workspaceMember, + ), + ...participants.filter((participant) => participant.workspaceMember), + ]; + + const participantsContainerRef = useRef(null); + + const StyledChips = orderedParticipants.map((participant) => ( + + )); + + return ( + + + + {Icon} + + + {responseStatus} + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx new file mode 100644 index 000000000000..1fbdeeb033f1 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -0,0 +1,181 @@ +import { useContext } from 'react'; +import { css, useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { format } from 'date-fns'; +import { useRecoilValue } from 'recoil'; +import { IconArrowRight, IconLock } from 'twenty-ui'; + +import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor'; +import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; +import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; +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 { Avatar } from '@/users/components/Avatar'; +import { AvatarGroup } from '@/users/components/AvatarGroup'; +import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; + +type CalendarEventRowProps = { + calendarEvent: TimelineCalendarEvent; + className?: string; +}; + +const StyledContainer = styled.div<{ showTitle?: boolean }>` + align-items: center; + display: inline-flex; + gap: ${({ theme }) => theme.spacing(3)}; + height: ${({ theme }) => theme.spacing(6)}; + position: relative; + cursor: ${({ showTitle }) => (showTitle ? 'pointer' : 'not-allowed')}; +`; + +const StyledAttendanceIndicator = styled.div<{ active?: boolean }>` + background-color: ${({ theme }) => theme.tag.background.gray}; + height: 100%; + width: ${({ theme }) => theme.spacing(1)}; + border-radius: ${({ theme }) => theme.border.radius.xs}; + + ${({ active, theme }) => + active && + css` + background-color: ${theme.tag.background.red}; + `} +`; + +const StyledLabels = styled.div` + align-items: center; + display: flex; + color: ${({ theme }) => theme.font.color.tertiary}; + gap: ${({ theme }) => theme.spacing(2)}; + flex: 1 0 auto; +`; + +const StyledTime = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + width: ${({ theme }) => theme.spacing(26)}; +`; + +const StyledTitle = styled.div<{ active: boolean; canceled: boolean }>` + flex: 1 0 auto; + + ${({ theme, active }) => + active && + css` + color: ${theme.font.color.primary}; + font-weight: ${theme.font.weight.medium}; + `} + + ${({ canceled }) => + canceled && + css` + text-decoration: line-through; + `} +`; + +const StyledVisibilityCard = styled(Card)<{ active: boolean }>` + color: ${({ active, theme }) => + active ? theme.font.color.primary : theme.font.color.light}; + border-color: ${({ theme }) => theme.border.color.light}; + flex: 1 0 auto; + transition: color ${({ theme }) => theme.animation.duration.normal} ease; +`; + +const StyledVisibilityCardContent = styled(CardContent)` + align-items: center; + font-size: ${({ theme }) => theme.font.size.sm}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + padding: ${({ theme }) => theme.spacing(0, 1)}; + height: ${({ theme }) => theme.spacing(6)}; + background-color: ${({ theme }) => theme.background.transparent.lighter}; +`; + +export const CalendarEventRow = ({ + calendarEvent, + className, +}: CalendarEventRowProps) => { + const theme = useTheme(); + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const { displayCurrentEventCursor = false } = useContext(CalendarContext); + const { openCalendarEventRightDrawer } = useOpenCalendarEventRightDrawer(); + + const startsAt = getCalendarEventStartDate(calendarEvent); + const endsAt = getCalendarEventEndDate(calendarEvent); + const hasEnded = hasCalendarEventEnded(calendarEvent); + + const startTimeLabel = calendarEvent.isFullDay + ? 'All day' + : format(startsAt, 'HH:mm'); + const endTimeLabel = calendarEvent.isFullDay ? '' : format(endsAt, 'HH:mm'); + + const isCurrentWorkspaceMemberAttending = calendarEvent.participants?.some( + ({ workspaceMemberId }) => workspaceMemberId === currentWorkspaceMember?.id, + ); + const showTitle = calendarEvent.visibility === 'SHARE_EVERYTHING'; + + return ( + openCalendarEventRightDrawer(calendarEvent.id) + : undefined + } + > + + + + {startTimeLabel} + {endTimeLabel && ( + <> + + {endTimeLabel} + + )} + + {showTitle ? ( + + {calendarEvent.title} + + ) : ( + + + + Not shared + + + )} + + {!!calendarEvent.participants?.length && ( + ( + + ))} + /> + )} + {displayCurrentEventCursor && ( + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx new file mode 100644 index 000000000000..f42f2e35f594 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx @@ -0,0 +1,29 @@ +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'; + +type CalendarMonthCardProps = { + dayTimes: number[]; +}; + +export const CalendarMonthCard = ({ dayTimes }: CalendarMonthCardProps) => { + const { calendarEventsByDayTime } = useContext(CalendarContext); + + return ( + + {dayTimes.map((dayTime, index) => { + const dayCalendarEvents = calendarEventsByDayTime[dayTime] || []; + + return ( + + ); + })} + + ); +}; 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 new file mode 100644 index 000000000000..82cba4e103e4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react'; + +import { Calendar } from '@/activities/calendar/components/Calendar'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +const meta: Meta = { + title: 'Modules/Activities/Calendar/Calendar', + component: Calendar, + decorators: [ComponentDecorator, SnackBarDecorator], + parameters: { + container: { width: 728 }, + msw: graphqlMocks, + }, + args: { + targetableObject: { + id: '1', + targetObjectNameSingular: 'Person', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/calendar/constants/Calendar.ts b/packages/twenty-front/src/modules/activities/calendar/constants/Calendar.ts new file mode 100644 index 000000000000..e958b80883d1 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/constants/Calendar.ts @@ -0,0 +1 @@ +export const TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE = 10; diff --git a/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts new file mode 100644 index 000000000000..423b48f822e1 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/contexts/CalendarContext.ts @@ -0,0 +1,19 @@ +import { createContext } from 'react'; + +import { TimelineCalendarEvent } from '~/generated-metadata/graphql'; + +type CalendarContextValue = { + calendarEventsByDayTime: Record; + currentCalendarEvent?: TimelineCalendarEvent; + displayCurrentEventCursor?: boolean; + getNextCalendarEvent: ( + calendarEvent: TimelineCalendarEvent, + ) => TimelineCalendarEvent | undefined; + updateCurrentCalendarEvent: () => void; +}; + +export const CalendarContext = createContext({ + calendarEventsByDayTime: {}, + getNextCalendarEvent: () => undefined, + updateCurrentCalendarEvent: () => {}, +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx b/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx new file mode 100644 index 000000000000..498cc5ff75af --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/__tests__/useCalendarEvents.test.tsx @@ -0,0 +1,53 @@ +import { act, renderHook } from '@testing-library/react'; + +import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +const calendarEvents: CalendarEvent[] = [ + { + id: '1234', + externalCreatedAt: '2024-02-17T20:45:43.854Z', + isFullDay: false, + startsAt: '2024-02-17T21:45:27.822Z', + visibility: 'METADATA', + }, + { + id: '5678', + externalCreatedAt: '2024-02-18T19:43:37.854Z', + isFullDay: false, + startsAt: '2024-02-18T21:43:27.754Z', + visibility: 'SHARE_EVERYTHING', + }, + { + id: '91011', + externalCreatedAt: '2024-02-19T20:45:20.854Z', + isFullDay: true, + startsAt: '2024-02-19T22:05:27.653Z', + visibility: 'METADATA', + }, + { + id: '121314', + externalCreatedAt: '2024-02-20T20:45:12.854Z', + isFullDay: true, + startsAt: '2024-02-20T23:15:23.150Z', + visibility: 'SHARE_EVERYTHING', + }, +]; + +describe('useCalendar', () => { + it('returns calendar events', () => { + const { result } = renderHook(() => useCalendarEvents(calendarEvents)); + + expect(result.current.currentCalendarEvent).toBe(calendarEvents[0]); + + expect(result.current.getNextCalendarEvent(calendarEvents[1])).toBe( + calendarEvents[0], + ); + + act(() => { + result.current.updateCurrentCalendarEvent(); + }); + + expect(result.current.currentCalendarEvent).toBe(calendarEvents[0]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts new file mode 100644 index 000000000000..5f2e633816e0 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/hooks/useCalendarEvents.ts @@ -0,0 +1,88 @@ +import { useMemo, useState } from 'react'; +import { getYear, isThisMonth, startOfDay, startOfMonth } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { findUpcomingCalendarEvent } from '@/activities/calendar/utils/findUpcomingCalendarEvent'; +import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; +import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; +import { isDefined } from '~/utils/isDefined'; +import { sortDesc } from '~/utils/sort'; + +type CalendarEventGeneric = Omit< + CalendarEvent, + 'participants' | 'externalCreatedAt' +>; + +export const useCalendarEvents = ( + calendarEvents: T[], +) => { + const calendarEventsByDayTime = groupArrayItemsBy( + calendarEvents, + (calendarEvent) => + startOfDay(getCalendarEventStartDate(calendarEvent)).getTime(), + ); + + const sortedDayTimes = Object.keys(calendarEventsByDayTime) + .map(Number) + .sort(sortDesc); + + const daysByMonthTime = groupArrayItemsBy(sortedDayTimes, (dayTime) => + startOfMonth(dayTime).getTime(), + ); + + const sortedMonthTimes = Object.keys(daysByMonthTime) + .map(Number) + .sort(sortDesc); + + const monthTimesByYear = groupArrayItemsBy(sortedMonthTimes, getYear); + + const getPreviousCalendarEvent = (calendarEvent: T) => { + const calendarEventIndex = calendarEvents.indexOf(calendarEvent); + return calendarEventIndex < calendarEvents.length - 1 + ? calendarEvents[calendarEventIndex + 1] + : undefined; + }; + + const getNextCalendarEvent = (calendarEvent: T) => { + const calendarEventIndex = calendarEvents.indexOf(calendarEvent); + return calendarEventIndex > 0 + ? calendarEvents[calendarEventIndex - 1] + : undefined; + }; + + const initialUpcomingCalendarEvent = useMemo( + () => findUpcomingCalendarEvent(calendarEvents), + [calendarEvents], + ); + const lastEventInCalendar = calendarEvents.length + ? calendarEvents[0] + : undefined; + + const [currentCalendarEvent, setCurrentCalendarEvent] = useState( + (initialUpcomingCalendarEvent && + (isThisMonth(getCalendarEventStartDate(initialUpcomingCalendarEvent)) + ? initialUpcomingCalendarEvent + : getPreviousCalendarEvent(initialUpcomingCalendarEvent))) || + lastEventInCalendar, + ); + + const updateCurrentCalendarEvent = () => { + if (!currentCalendarEvent) return; + + const nextCurrentCalendarEvent = getNextCalendarEvent(currentCalendarEvent); + + if (isDefined(nextCurrentCalendarEvent)) { + setCurrentCalendarEvent(nextCurrentCalendarEvent); + } + }; + + return { + calendarEventsByDayTime, + currentCalendarEvent, + daysByMonthTime, + getNextCalendarEvent, + monthTimes: sortedMonthTimes, + monthTimesByYear, + updateCurrentCalendarEvent, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventFragment.ts b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventFragment.ts new file mode 100644 index 000000000000..d98c7bcf81b2 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventFragment.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventParticipantFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment'; + +export const timelineCalendarEventFragment = gql` + fragment TimelineCalendarEventFragment on TimelineCalendarEvent { + id + title + description + location + startsAt + endsAt + isFullDay + visibility + participants { + ...TimelineCalendarEventParticipantFragment + } + } + ${timelineCalendarEventParticipantFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment.ts b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment.ts new file mode 100644 index 000000000000..7a48eea69453 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +export const timelineCalendarEventParticipantFragment = gql` + fragment TimelineCalendarEventParticipantFragment on TimelineCalendarEventParticipant { + personId + workspaceMemberId + firstName + lastName + displayName + avatarUrl + handle + } +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment.ts b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment.ts new file mode 100644 index 000000000000..2a76f0f7fa41 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment.ts @@ -0,0 +1,13 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventFragment'; + +export const timelineCalendarEventWithTotalFragment = gql` + fragment TimelineCalendarEventsWithTotalFragment on TimelineCalendarEventsWithTotal { + totalNumberOfCalendarEvents + timelineCalendarEvents { + ...TimelineCalendarEventFragment + } + } + ${timelineCalendarEventFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId.ts new file mode 100644 index 000000000000..e454e67452f3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment'; + +export const getTimelineCalendarEventsFromCompanyId = gql` + query GetTimelineCalendarEventsFromCompanyId( + $companyId: UUID! + $page: Int! + $pageSize: Int! + ) { + getTimelineCalendarEventsFromCompanyId( + companyId: $companyId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } + } + ${timelineCalendarEventWithTotalFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromPersonId.ts b/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromPersonId.ts new file mode 100644 index 000000000000..7d9f221fbc78 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromPersonId.ts @@ -0,0 +1,20 @@ +import { gql } from '@apollo/client'; + +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment'; + +export const getTimelineCalendarEventsFromPersonId = gql` + query GetTimelineCalendarEventsFromPersonId( + $personId: UUID! + $page: Int! + $pageSize: Int! + ) { + getTimelineCalendarEventsFromPersonId( + personId: $personId + page: $page + pageSize: $pageSize + ) { + ...TimelineCalendarEventsWithTotalFragment + } + } + ${timelineCalendarEventWithTotalFragment} +`; diff --git a/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx new file mode 100644 index 000000000000..f0e895dcdaa5 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/right-drawer/components/RightDrawerCalendarEvent.tsx @@ -0,0 +1,23 @@ +import { useRecoilValue } from 'recoil'; + +import { CalendarEventDetails } from '@/activities/calendar/components/CalendarEventDetails'; +import { viewableCalendarEventIdState } from '@/activities/calendar/states/viewableCalendarEventIdState'; +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; + +export const RightDrawerCalendarEvent = () => { + const { setRecords } = useSetRecordInStore(); + const viewableCalendarEventId = useRecoilValue(viewableCalendarEventIdState); + const { record: calendarEvent } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.CalendarEvent, + objectRecordId: viewableCalendarEventId ?? '', + onCompleted: (record) => setRecords([record]), + depth: 2, + }); + + if (!calendarEvent) return null; + + return ; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/__tests__/useOpenCalendarEventRightDrawer.test.tsx b/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/__tests__/useOpenCalendarEventRightDrawer.test.tsx new file mode 100644 index 000000000000..027f7f87557a --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/__tests__/useOpenCalendarEventRightDrawer.test.tsx @@ -0,0 +1,35 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +import { useOpenCalendarEventRightDrawer } from '@/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer'; +import { viewableCalendarEventIdState } from '@/activities/calendar/states/viewableCalendarEventIdState'; +import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; + +describe('useOpenCalendarEventRightDrawer', () => { + it('opens the right drawer with the calendar event', () => { + const { result } = renderHook( + () => { + const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState); + const viewableCalendarEventId = useRecoilValue( + viewableCalendarEventIdState, + ); + return { + ...useOpenCalendarEventRightDrawer(), + isRightDrawerOpen, + viewableCalendarEventId, + }; + }, + { wrapper: RecoilRoot }, + ); + + expect(result.current.isRightDrawerOpen).toBe(false); + expect(result.current.viewableCalendarEventId).toBeNull(); + + act(() => { + result.current.openCalendarEventRightDrawer('1234'); + }); + + expect(result.current.isRightDrawerOpen).toBe(true); + expect(result.current.viewableCalendarEventId).toBe('1234'); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts b/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts new file mode 100644 index 000000000000..9cd27d017e52 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/right-drawer/hooks/useOpenCalendarEventRightDrawer.ts @@ -0,0 +1,23 @@ +import { useSetRecoilState } from 'recoil'; + +import { viewableCalendarEventIdState } from '@/activities/calendar/states/viewableCalendarEventIdState'; +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'; + +export const useOpenCalendarEventRightDrawer = () => { + const { openRightDrawer } = useRightDrawer(); + const setHotkeyScope = useSetHotkeyScope(); + const setViewableCalendarEventId = useSetRecoilState( + viewableCalendarEventIdState, + ); + + const openCalendarEventRightDrawer = (calendarEventId: string) => { + setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); + openRightDrawer(RightDrawerPages.ViewCalendarEvent); + setViewableCalendarEventId(calendarEventId); + }; + + return { openCalendarEventRightDrawer }; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/states/viewableCalendarEventIdState.ts b/packages/twenty-front/src/modules/activities/calendar/states/viewableCalendarEventIdState.ts new file mode 100644 index 000000000000..32d1951bcf5b --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/states/viewableCalendarEventIdState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const viewableCalendarEventIdState = createState({ + key: 'viewableCalendarEventIdState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts new file mode 100644 index 000000000000..a598c23d967f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEvent.ts @@ -0,0 +1,20 @@ +import { CalendarEventParticipant } from '@/activities/calendar/types/CalendarEventParticipant'; + +// TODO: use backend CalendarEvent type when ready +export type CalendarEvent = { + conferenceLink?: { + label: string; + url: string; + }; + description?: string; + endsAt?: string; + externalCreatedAt: string; + id: string; + isCanceled?: boolean; + isFullDay: boolean; + location?: string; + startsAt: string; + title?: string; + visibility: 'METADATA' | 'SHARE_EVERYTHING'; + calendarEventParticipants?: CalendarEventParticipant[]; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/types/CalendarEventParticipant.ts b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEventParticipant.ts new file mode 100644 index 000000000000..079e57c18d31 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/types/CalendarEventParticipant.ts @@ -0,0 +1,12 @@ +import { Person } from '@/people/types/Person'; +import { WorkspaceMember } from '~/generated-metadata/graphql'; + +export type CalendarEventParticipant = { + id: string; + handle: string; + isOrganizer: boolean; + displayName: string; + person?: Person; + workspaceMember?: WorkspaceMember; + responseStatus: 'ACCEPTED' | 'DECLINED' | 'NEEDS_ACTION' | 'TENTATIVE'; +}; diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/findUpcomingCalendarEvent.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/findUpcomingCalendarEvent.test.ts new file mode 100644 index 000000000000..dce86cc71c20 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/findUpcomingCalendarEvent.test.ts @@ -0,0 +1,80 @@ +import { addDays, addHours, startOfDay, subDays, subHours } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +import { findUpcomingCalendarEvent } from '../findUpcomingCalendarEvent'; + +const pastEvent: Pick = { + startsAt: subHours(new Date(), 2).toISOString(), + endsAt: subHours(new Date(), 1).toISOString(), + isFullDay: false, +}; +const fullDayPastEvent: Pick = { + startsAt: subDays(new Date(), 1).toISOString(), + isFullDay: true, +}; + +const currentEvent: Pick = { + startsAt: addHours(new Date(), 1).toISOString(), + endsAt: addHours(new Date(), 2).toISOString(), + isFullDay: false, +}; +const currentFullDayEvent: Pick = { + startsAt: startOfDay(new Date()).toISOString(), + isFullDay: true, +}; + +const futureEvent: Pick = { + startsAt: addDays(new Date(), 1).toISOString(), + endsAt: addDays(new Date(), 2).toISOString(), + isFullDay: false, +}; +const fullDayFutureEvent: Pick = { + startsAt: addDays(new Date(), 2).toISOString(), + isFullDay: false, +}; + +describe('findUpcomingCalendarEvent', () => { + it('returns the first current event by chronological order', () => { + // Given + const calendarEvents = [ + futureEvent, + currentFullDayEvent, + pastEvent, + currentEvent, + ]; + + // When + const result = findUpcomingCalendarEvent(calendarEvents); + + // Then + expect(result).toEqual(currentFullDayEvent); + }); + + it('returns the next future event by chronological order', () => { + // Given + const calendarEvents = [ + fullDayPastEvent, + fullDayFutureEvent, + futureEvent, + pastEvent, + ]; + + // When + const result = findUpcomingCalendarEvent(calendarEvents); + + // Then + expect(result).toEqual(futureEvent); + }); + + it('returns undefined if all events are in the past', () => { + // Given + const calendarEvents = [pastEvent, fullDayPastEvent]; + + // When + const result = findUpcomingCalendarEvent(calendarEvents); + + // Then + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventEnded.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventEnded.test.ts new file mode 100644 index 000000000000..7519fb7e06f2 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventEnded.test.ts @@ -0,0 +1,88 @@ +import { addDays, addHours, subDays, subHours } from 'date-fns'; + +import { hasCalendarEventEnded } from '../hasCalendarEventEnded'; + +describe('hasCalendarEventEnded', () => { + describe('Event with end date', () => { + it('returns true for an event with a past end date', () => { + // Given + const startsAt = subHours(new Date(), 2).toISOString(); + const endsAt = subHours(new Date(), 1).toISOString(); + const isFullDay = false; + + // When + const result = hasCalendarEventEnded({ + startsAt, + endsAt, + isFullDay, + }); + + // Then + expect(result).toBe(true); + }); + + it('returns false for an event with a future end date', () => { + // Given + const startsAt = new Date().toISOString(); + const endsAt = addHours(new Date(), 1).toISOString(); + const isFullDay = false; + + // When + const result = hasCalendarEventEnded({ + startsAt, + endsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + }); + + describe('Full day event', () => { + it('returns true for a past full day event', () => { + // Given + const startsAt = subDays(new Date(), 1).toISOString(); + const isFullDay = true; + + // When + const result = hasCalendarEventEnded({ + startsAt, + isFullDay, + }); + + // Then + expect(result).toBe(true); + }); + + it('returns false for a future full day event', () => { + // Given + const startsAt = addDays(new Date(), 1).toISOString(); + const isFullDay = true; + + // When + const result = hasCalendarEventEnded({ + startsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + + it('returns false if the full day event is today', () => { + // Given + const startsAt = new Date().toISOString(); + const isFullDay = true; + + // When + const result = hasCalendarEventEnded({ + startsAt, + isFullDay, + }); + + // Then + expect(result).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventStarted.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventStarted.test.ts new file mode 100644 index 000000000000..5e0ddaea4756 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/hasCalendarEventStarted.test.ts @@ -0,0 +1,38 @@ +import { addHours, subHours } from 'date-fns'; + +import { hasCalendarEventStarted } from '../hasCalendarEventStarted'; + +describe('hasCalendarEventStarted', () => { + it('returns true for an event with a past start date', () => { + // Given + const startsAt = subHours(new Date(), 2).toISOString(); + + // When + const result = hasCalendarEventStarted({ startsAt }); + + // Then + expect(result).toBe(true); + }); + + it('returns false for an event if start date is now', () => { + // Given + const startsAt = new Date().toISOString(); + + // When + const result = hasCalendarEventStarted({ startsAt }); + + // Then + expect(result).toBe(false); + }); + + it('returns false for an event with a future start date', () => { + // Given + const startsAt = addHours(new Date(), 1).toISOString(); + + // When + const result = hasCalendarEventStarted({ startsAt }); + + // Then + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts new file mode 100644 index 000000000000..dc1a42a9cc16 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/__tests__/sortCalendarEvents.test.ts @@ -0,0 +1,313 @@ +import { addHours } from 'date-fns'; + +import { + sortCalendarEventsAsc, + sortCalendarEventsDesc, +} from '../sortCalendarEvents'; + +const someDate = new Date(2000, 1, 1); +const someDatePlusOneHour = addHours(someDate, 1); +const someDatePlusTwoHours = addHours(someDate, 2); +const someDatePlusThreeHours = addHours(someDate, 3); + +describe('sortCalendarEventsAsc', () => { + it('sorts non-intersecting events by ascending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusOneHour.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDatePlusTwoHours.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts intersecting events by start date ascending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusTwoHours.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDatePlusOneHour.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts events with same start date by end date ascending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusTwoHours.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts events with same end date by start date ascending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDatePlusOneHour.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('sorts events without end date by start date ascending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + const eventB = { + startsAt: someDatePlusOneHour.toISOString(), + isFullDay: true, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); + + it('returns 0 for full day events with the same start date', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + const eventB = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(0); + expect(invertedArgsResult).toBe(0); + }); + + it('sorts the full day event last for two events with the same start date if one is full day', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusOneHour.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + + // When + const result = sortCalendarEventsAsc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsAsc(eventB, eventA); + + // Then + expect(result).toBe(-1); + expect(invertedArgsResult).toBe(1); + }); +}); + +describe('sortCalendarEventsDesc', () => { + it('sorts non-intersecting events by descending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusOneHour.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDatePlusTwoHours.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts intersecting events by start date descending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusTwoHours.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDatePlusOneHour.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts events with same start date by end date descending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusTwoHours.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts events with same end date by start date descending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDatePlusOneHour.toISOString(), + endsAt: someDatePlusThreeHours.toISOString(), + isFullDay: false, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('sorts events without end date by start date descending order', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + const eventB = { + startsAt: someDatePlusOneHour.toISOString(), + isFullDay: true, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); + + it('returns 0 for full day events with the same start date', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + const eventB = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result === 0).toBe(true); + expect(invertedArgsResult === 0).toBe(true); + }); + + it('sorts the full day event first for two events with the same start date if one is full day', () => { + // Given + const eventA = { + startsAt: someDate.toISOString(), + endsAt: someDatePlusOneHour.toISOString(), + isFullDay: false, + }; + const eventB = { + startsAt: someDate.toISOString(), + isFullDay: true, + }; + + // When + const result = sortCalendarEventsDesc(eventA, eventB); + const invertedArgsResult = sortCalendarEventsDesc(eventB, eventA); + + // Then + expect(result).toBe(1); + expect(invertedArgsResult).toBe(-1); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/findUpcomingCalendarEvent.ts b/packages/twenty-front/src/modules/activities/calendar/utils/findUpcomingCalendarEvent.ts new file mode 100644 index 000000000000..250a4511281e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/findUpcomingCalendarEvent.ts @@ -0,0 +1,12 @@ +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; +import { sortCalendarEventsAsc } from '@/activities/calendar/utils/sortCalendarEvents'; + +export const findUpcomingCalendarEvent = < + T extends Pick, +>( + calendarEvents: T[], +) => + [...calendarEvents] + .sort(sortCalendarEventsAsc) + .find((calendarEvent) => !hasCalendarEventEnded(calendarEvent)); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventEndDate.ts b/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventEndDate.ts new file mode 100644 index 000000000000..8463f2236faa --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventEndDate.ts @@ -0,0 +1,11 @@ +import { endOfDay } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; + +export const getCalendarEventEndDate = ( + calendarEvent: Pick, +) => + calendarEvent.endsAt + ? new Date(calendarEvent.endsAt) + : endOfDay(getCalendarEventStartDate(calendarEvent)); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventStartDate.ts b/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventStartDate.ts new file mode 100644 index 000000000000..db7a08de3ee3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/getCalendarEventStartDate.ts @@ -0,0 +1,5 @@ +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; + +export const getCalendarEventStartDate = ( + calendarEvent: Pick, +) => new Date(calendarEvent.startsAt); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventEnded.ts b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventEnded.ts new file mode 100644 index 000000000000..9800634db746 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventEnded.ts @@ -0,0 +1,8 @@ +import { isPast } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; + +export const hasCalendarEventEnded = ( + calendarEvent: Pick, +) => isPast(getCalendarEventEndDate(calendarEvent)); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventStarted.ts b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventStarted.ts new file mode 100644 index 000000000000..f351edc9d90d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/hasCalendarEventStarted.ts @@ -0,0 +1,8 @@ +import { isPast } from 'date-fns'; + +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; + +export const hasCalendarEventStarted = ( + calendarEvent: Pick, +) => isPast(getCalendarEventStartDate(calendarEvent)); diff --git a/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts b/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts new file mode 100644 index 000000000000..8ea50406c36c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/calendar/utils/sortCalendarEvents.ts @@ -0,0 +1,26 @@ +import { CalendarEvent } from '@/activities/calendar/types/CalendarEvent'; +import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendarEventEndDate'; +import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; +import { sortAsc } from '~/utils/sort'; + +export const sortCalendarEventsAsc = ( + calendarEventA: Pick, + calendarEventB: Pick, +) => { + const startsAtSort = sortAsc( + getCalendarEventStartDate(calendarEventA).getTime(), + getCalendarEventStartDate(calendarEventB).getTime(), + ); + + if (startsAtSort !== 0) return startsAtSort; + + return sortAsc( + getCalendarEventEndDate(calendarEventA).getTime(), + getCalendarEventEndDate(calendarEventB).getTime(), + ); +}; + +export const sortCalendarEventsDesc = ( + calendarEventA: Pick, + calendarEventB: Pick, +) => -sortCalendarEventsAsc(calendarEventA, calendarEventB); diff --git a/packages/twenty-front/src/modules/activities/comment/CommentCounter.tsx b/packages/twenty-front/src/modules/activities/comment/CommentCounter.tsx index 52274fdf54da..29abcea3ab2f 100644 --- a/packages/twenty-front/src/modules/activities/comment/CommentCounter.tsx +++ b/packages/twenty-front/src/modules/activities/comment/CommentCounter.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; - -import { IconComment } from '@/ui/display/icon'; +import { IconComment } from 'twenty-ui'; const StyledCommentIcon = styled.div` align-items: center; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx index fc2408ba91e2..0b3994278a6c 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityBodyEditor.tsx @@ -1,22 +1,22 @@ -import { useCallback, useMemo } from 'react'; -import { BlockNoteEditor } from '@blocknote/core'; -import { useBlockNote } from '@blocknote/react'; -import styled from '@emotion/styled'; +import { ClipboardEvent, useCallback, useMemo } from 'react'; +import { useApolloClient } from '@apollo/client'; +import { useCreateBlockNote } from '@blocknote/react'; import { isArray, isNonEmptyString } from '@sniptt/guards'; 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'; import { canCreateActivityState } from '@/activities/states/canCreateActivityState'; import { Activity } from '@/activities/types/Activity'; import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; +import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { BlockEditor } from '@/ui/input/editor/components/BlockEditor'; import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; @@ -25,18 +25,13 @@ import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { FileFolder, useUploadFileMutation } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { blockSpecs } from '../blocks/blockSpecs'; -import { getSlashMenu } from '../blocks/slashMenu'; import { getFileType } from '../files/utils/getFileType'; import '@blocknote/react/style.css'; -const StyledBlockNoteStyledContainer = styled.div` - height: 100%; - width: 100%; -`; - type ActivityBodyEditorProps = { activityId: string; fillTitleFromBody: boolean; @@ -47,7 +42,7 @@ export const ActivityBodyEditor = ({ fillTitleFromBody, }: ActivityBodyEditorProps) => { const [activityInStore] = useRecoilState(recordStoreFamilyState(activityId)); - + const cache = useApolloClient().cache; const activity = activityInStore as Activity | null; const [activityTitleHasBeenSet, setActivityTitleHasBeenSet] = useRecoilState( @@ -63,13 +58,10 @@ export const ActivityBodyEditor = ({ ); const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItemOnly({ + useObjectMetadataItem({ objectNameSingular: CoreObjectNameSingular.Activity, }); - const modifyActivityFromCache = useModifyRecordFromCache({ - objectMetadataItem: objectMetadataItemActivity, - }); const { goBackToPreviousHotkeyScope, setHotkeyScopeAndMemorizePreviousScope, @@ -78,7 +70,7 @@ export const ActivityBodyEditor = ({ const { upsertActivity } = useUpsertActivity(); const persistBodyDebounced = useDebouncedCallback((newBody: string) => { - if (activity) { + if (isDefined(activity)) { upsertActivity({ activity, input: { @@ -90,7 +82,7 @@ export const ActivityBodyEditor = ({ const persistTitleAndBodyDebounced = useDebouncedCallback( (newTitle: string, newBody: string) => { - if (activity) { + if (isDefined(activity)) { upsertActivity({ activity, input: { @@ -119,12 +111,10 @@ export const ActivityBodyEditor = ({ canCreateActivityState, ); - const slashMenuItems = getSlashMenu(); - const [uploadFile] = useUploadFileMutation(); const handleUploadAttachment = async (file: File): Promise => { - if (!file) { + if (isUndefinedOrNull(file)) { return ''; } const result = await uploadFile({ @@ -174,10 +164,15 @@ export const ActivityBodyEditor = ({ }; }); - modifyActivityFromCache(activityId, { - body: () => { - return newStringifiedBody; + modifyRecordFromCache({ + recordId: activityId, + fieldModifiers: { + body: () => { + return newStringifiedBody; + }, }, + cache, + objectMetadataItem: objectMetadataItemActivity, }); const activityTitleHasBeenSet = snapshot @@ -200,22 +195,33 @@ export const ActivityBodyEditor = ({ }; }); - modifyActivityFromCache(activityId, { - title: () => { - return newTitleFromBody; + modifyRecordFromCache({ + recordId: activityId, + fieldModifiers: { + title: () => { + return newTitleFromBody; + }, }, + cache, + objectMetadataItem: objectMetadataItemActivity, }); } handlePersistBody(newStringifiedBody); }, - [activityId, fillTitleFromBody, modifyActivityFromCache, handlePersistBody], + [ + activityId, + cache, + objectMetadataItemActivity, + fillTitleFromBody, + handlePersistBody, + ], ); const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500); - const handleEditorChange = (newEditor: BlockNoteEditor) => { - const newStringifiedBody = JSON.stringify(newEditor.topLevelBlocks) ?? ''; + const handleEditorChange = () => { + const newStringifiedBody = JSON.stringify(editor.document) ?? ''; setActivityBody(newStringifiedBody); @@ -226,7 +232,7 @@ export const ActivityBodyEditor = ({ if (isNonEmptyString(activityBody) && activityBody !== '{}') { return JSON.parse(activityBody); } else if ( - activity && + isDefined(activity) && isNonEmptyString(activity.body) && activity?.body !== '{}' ) { @@ -236,22 +242,17 @@ export const ActivityBodyEditor = ({ } }, [activity, activityBody]); - const editor: BlockNoteEditor | null = useBlockNote({ + const editor = useCreateBlockNote({ initialContent: initialBody, domAttributes: { editor: { class: 'editor' } }, - onEditorContentChange: handleEditorChange, - slashMenuItems, - blockSpecs: blockSpecs, + schema: blockSchema, uploadFile: handleUploadAttachment, - onEditorReady: (editor: BlockNoteEditor) => { - editor.domElement.addEventListener('paste', handleImagePaste); - }, }); const handleImagePaste = async (event: ClipboardEvent) => { const clipboardItems = event.clipboardData?.items; - if (clipboardItems) { + if (isDefined(clipboardItems)) { for (let i = 0; i < clipboardItems.length; i++) { if (clipboardItems[i].kind === 'file') { const isImage = clipboardItems[i].type.match('^image/'); @@ -266,7 +267,7 @@ export const ActivityBodyEditor = ({ return; } - if (isImage) { + if (isDefined(isImage)) { editor?.insertBlocks( [ { @@ -332,7 +333,7 @@ export const ActivityBodyEditor = ({ const currentBlockContent = blockIdentifier?.content; if ( - currentBlockContent && + isDefined(currentBlockContent) && isArray(currentBlockContent) && currentBlockContent.length === 0 ) { @@ -344,9 +345,9 @@ export const ActivityBodyEditor = ({ } if ( - currentBlockContent && + isDefined(currentBlockContent) && isArray(currentBlockContent) && - currentBlockContent[0] && + isDefined(currentBlockContent[0]) && currentBlockContent[0].type === 'text' ) { // Text block case @@ -359,7 +360,7 @@ export const ActivityBodyEditor = ({ const newBlockId = v4(); const newBlock = { id: newBlockId, - type: 'paragraph', + type: 'paragraph' as const, content: keyboardEvent.key, }; editor.insertBlocks([newBlock], blockIdentifier, 'after'); @@ -381,12 +382,12 @@ export const ActivityBodyEditor = ({ }; return ( - editor.focus()}> - - + ); }; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityBodyEffect.tsx b/packages/twenty-front/src/modules/activities/components/ActivityBodyEffect.tsx index 7da880ed189c..221d087209e7 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityBodyEffect.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityBodyEffect.tsx @@ -3,6 +3,7 @@ import { useRecoilState } from 'recoil'; import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { isDefined } from '~/utils/isDefined'; export const ActivityBodyEffect = ({ activityId }: { activityId: string }) => { const [activityFromStore] = useRecoilState( @@ -16,7 +17,7 @@ export const ActivityBodyEffect = ({ activityId }: { activityId: string }) => { useEffect(() => { if ( activityBody === '' && - activityFromStore && + isDefined(activityFromStore) && activityBody !== activityFromStore.body ) { setActivityBody(activityFromStore.body); diff --git a/packages/twenty-front/src/modules/activities/components/ActivityEditorEffect.tsx b/packages/twenty-front/src/modules/activities/components/ActivityEditorEffect.tsx index cffc5e0799eb..3d6a03d994ac 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityEditorEffect.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityEditorEffect.tsx @@ -1,6 +1,5 @@ import { useRecoilCallback } from 'recoil'; -import { useDeleteActivityFromCache } from '@/activities/hooks/useDeleteActivityFromCache'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState'; import { activityTitleFamilyState } from '@/activities/states/activityTitleFamilyState'; @@ -8,9 +7,12 @@ import { canCreateActivityState } from '@/activities/states/canCreateActivitySta import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; import { Activity } from '@/activities/types/Activity'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useDeleteRecordFromCache } from '@/object-record/cache/hooks/useDeleteRecordFromCache'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; +import { isDefined } from '~/utils/isDefined'; export const ActivityEditorEffect = ({ activityId, @@ -22,7 +24,9 @@ export const ActivityEditorEffect = ({ ); const { upsertActivity } = useUpsertActivity(); - const { deleteActivityFromCache } = useDeleteActivityFromCache(); + const deleteRecordFromCache = useDeleteRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); const upsertActivityCallback = useRecoilCallback( ({ snapshot, set }) => @@ -57,7 +61,7 @@ export const ActivityEditorEffect = ({ return; } - if (isActivityInCreateMode && activity) { + if (isActivityInCreateMode && isDefined(activity)) { if (canCreateActivity) { upsertActivity({ activity, @@ -67,11 +71,11 @@ export const ActivityEditorEffect = ({ }, }); } else { - deleteActivityFromCache(activity); + deleteRecordFromCache(activity); } set(isActivityInCreateModeState, false); - } else if (activity) { + } else if (isDefined(activity)) { if ( activity.title !== activityTitle || activity.body !== activityBody @@ -86,7 +90,7 @@ export const ActivityEditorEffect = ({ } } }, - [activityId, deleteActivityFromCache, upsertActivity], + [activityId, deleteRecordFromCache, upsertActivity], ); useRegisterClickOutsideListenerCallback({ diff --git a/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx b/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx index 126ce0de5358..31ae39ceef38 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityEditorFields.tsx @@ -1,10 +1,11 @@ import styled from '@emotion/styled'; -import { useRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell'; import { Activity } from '@/activities/types/Activity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { useFieldContext } from '@/object-record/hooks/useFieldContext'; import { RecordUpdateHook, @@ -13,6 +14,7 @@ import { import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { isDefined } from '~/utils/isDefined'; const StyledPropertyBox = styled(PropertyBox)` padding: 0; @@ -25,9 +27,13 @@ export const ActivityEditorFields = ({ }) => { const { upsertActivity } = useUpsertActivity(); - const [activityFromStore] = useRecoilState( - recordStoreFamilyState(activityId), - ); + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); + + const activityFromCache = getRecordFromCache(activityId); + + const activityFromStore = useRecoilValue(recordStoreFamilyState(activityId)); const activity = activityFromStore as Activity; @@ -35,7 +41,7 @@ export const ActivityEditorFields = ({ const upsertActivityMutation = async ({ variables, }: RecordUpdateHookParams) => { - if (activityFromStore) { + if (isDefined(activityFromStore)) { await upsertActivity({ activity: activityFromStore as Activity, input: variables.updateOneRecordInput, @@ -87,9 +93,9 @@ export const ActivityEditorFields = ({ )} - {ActivityTargetsContextProvider && ( + {ActivityTargetsContextProvider && isDefined(activityFromCache) && ( - + )} diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx index c88d5f5e6f79..d5d37739c1f7 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTargetChips.tsx @@ -1,7 +1,14 @@ +import { useMemo } from 'react'; import styled from '@emotion/styled'; +import { v4 } from 'uuid'; import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { RecordChip } from '@/object-record/components/RecordChip'; +import { Chip, ChipVariant } from '@/ui/display/chip/components/Chip'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { RGBA } from '@/ui/theme/constants/Rgba'; + +const MAX_RECORD_CHIPS_DISPLAY = 2; const StyledContainer = styled.div` display: flex; @@ -9,22 +16,77 @@ const StyledContainer = styled.div` gap: ${({ theme }) => theme.spacing(1)}; `; +const StyledRelationsListContainer = styled(StyledContainer)` + padding: ${({ theme }) => theme.spacing(2)}; + border-radius: ${({ theme }) => theme.spacing(1)}; + background-color: ${({ theme }) => RGBA(theme.color.gray10, 0.8)}; + box-shadow: '0px 2px 4px ${({ theme }) => + theme.boxShadow.light}, 2px 4px 16px ${({ theme }) => + theme.boxShadow.strong}'; + backdrop-filter: ${({ theme }) => theme.blur.strong}; +`; + +const showMoreRelationsHandler = (event?: React.MouseEvent) => { + event?.preventDefault(); + event?.stopPropagation(); +}; + export const ActivityTargetChips = ({ activityTargetObjectRecords, }: { activityTargetObjectRecords: ActivityTargetWithTargetRecord[]; }) => { + const dropdownId = useMemo(() => `multiple-relations-dropdown-${v4()}`, []); + return ( - {activityTargetObjectRecords?.map((activityTargetObjectRecord) => ( - - ))} + {activityTargetObjectRecords + ?.slice(0, MAX_RECORD_CHIPS_DISPLAY) + .map((activityTargetObjectRecord) => ( + + ))} + + {activityTargetObjectRecords.length > MAX_RECORD_CHIPS_DISPLAY && ( +
+ + } + dropdownOffset={{ x: 0, y: -20 }} + dropdownComponents={ + + {activityTargetObjectRecords.map( + (activityTargetObjectRecord) => ( + + ), + )} + + } + /> +
+ )}
); }; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTitle.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTitle.tsx index 4eb7b38033b4..288447dbec41 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTitle.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTitle.tsx @@ -1,4 +1,5 @@ import { useRef } from 'react'; +import { useApolloClient } from '@apollo/client'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilState } from 'recoil'; @@ -11,9 +12,9 @@ import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activity import { canCreateActivityState } from '@/activities/states/canCreateActivityState'; import { Activity } from '@/activities/types/Activity'; import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotkeyScope'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; +import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { Checkbox, @@ -22,7 +23,7 @@ import { } from '@/ui/input/components/Checkbox'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; const StyledEditableTitleInput = styled.input<{ completed: boolean; @@ -64,6 +65,8 @@ export const ActivityTitle = ({ activityId }: ActivityTitleProps) => { recordStoreFamilyState(activityId), ); + const cache = useApolloClient().cache; + const [activityTitle, setActivityTitle] = useRecoilState( activityTitleFamilyState({ activityId }), ); @@ -108,14 +111,10 @@ export const ActivityTitle = ({ activityId }: ActivityTitleProps) => { ); const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItemOnly({ + useObjectMetadataItem({ objectNameSingular: CoreObjectNameSingular.Activity, }); - const modifyActivityFromCache = useModifyRecordFromCache({ - objectMetadataItem: objectMetadataItemActivity, - }); - const persistTitleDebounced = useDebouncedCallback((newTitle: string) => { upsertActivity({ activity, @@ -142,10 +141,15 @@ export const ActivityTitle = ({ activityId }: ActivityTitleProps) => { setCanCreateActivity(true); } - modifyActivityFromCache(activity.id, { - title: () => { - return newTitle; + modifyRecordFromCache({ + recordId: activity.id, + fieldModifiers: { + title: () => { + return newTitle; + }, }, + cache: cache, + objectMetadataItem: objectMetadataItemActivity, }); }, 500); @@ -166,7 +170,7 @@ export const ActivityTitle = ({ activityId }: ActivityTitleProps) => { }); }; - const completed = isNonNullable(activity.completedAt); + const completed = isDefined(activity.completedAt); return ( diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTitleEffect.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTitleEffect.tsx index c5fcadaa7cf0..50d3e4dffd59 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTitleEffect.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTitleEffect.tsx @@ -3,6 +3,7 @@ import { useRecoilState } from 'recoil'; import { activityTitleFamilyState } from '@/activities/states/activityTitleFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { isDefined } from '~/utils/isDefined'; export const ActivityTitleEffect = ({ activityId }: { activityId: string }) => { const [activityFromStore] = useRecoilState( @@ -16,7 +17,7 @@ export const ActivityTitleEffect = ({ activityId }: { activityId: string }) => { useEffect(() => { if ( activityTitle === '' && - activityFromStore && + isDefined(activityFromStore) && activityTitle !== activityFromStore.title ) { setActivityTitle(activityFromStore.title); diff --git a/packages/twenty-front/src/modules/activities/components/ActivityTypeDropdown.tsx b/packages/twenty-front/src/modules/activities/components/ActivityTypeDropdown.tsx index d1b927e3550c..40015fee55b0 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityTypeDropdown.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityTypeDropdown.tsx @@ -1,5 +1,6 @@ import { useTheme } from '@emotion/react'; import { useRecoilState } from 'recoil'; +import { IconCheckbox, IconNotes } from 'twenty-ui'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { @@ -8,7 +9,6 @@ import { ChipSize, ChipVariant, } from '@/ui/display/chip/components/Chip'; -import { IconCheckbox, IconNotes } from '@/ui/display/icon'; type ActivityTypeDropdownProps = { activityId: string; diff --git a/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx b/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx new file mode 100644 index 000000000000..5bc3c1eb1252 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/components/CustomResolverFetchMoreLoader.tsx @@ -0,0 +1,34 @@ +import { useInView } from 'react-intersection-observer'; +import styled from '@emotion/styled'; + +import { GRAY_SCALE } from '@/ui/theme/constants/GrayScale'; + +type FetchMoreLoaderProps = { + loading: boolean; + onLastRowVisible: (...args: any[]) => any; +}; + +const StyledText = styled.div` + align-items: center; + box-shadow: none; + color: ${GRAY_SCALE.gray40}; + display: flex; + height: 32px; + margin-left: ${({ theme }) => theme.spacing(8)}; + padding-left: ${({ theme }) => theme.spacing(2)}; +`; + +export const FetchMoreLoader = ({ + loading, + onLastRowVisible, +}: FetchMoreLoaderProps) => { + const { ref: tbodyRef } = useInView({ + onChange: onLastRowVisible, + }); + + return ( +
+ {loading && Loading more...} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx b/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx new file mode 100644 index 000000000000..84235d6a912e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/components/ParticipantChip.tsx @@ -0,0 +1,80 @@ +import styled from '@emotion/styled'; + +import { getDisplayNameFromParticipant } from '@/activities/emails/utils/getDisplayNameFromParticipant'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { RecordChip } from '@/object-record/components/RecordChip'; +import { Avatar } from '@/users/components/Avatar'; + +const StyledAvatar = styled(Avatar)` + margin-right: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledSenderName = styled.span<{ variant?: 'default' | 'bold' }>` + color: ${({ theme }) => theme.font.color.primary}; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme, variant }) => + variant === 'bold' ? theme.font.weight.medium : theme.font.weight.regular}; + overflow: hidden; + text-overflow: ellipsis; +`; + +const StyledContainer = styled.div` + align-items: flex-start; + display: flex; +`; + +const StyledRecordChip = styled(RecordChip)<{ variant: 'default' | 'bold' }>` + font-weight: ${({ theme, variant }) => + variant === 'bold' ? theme.font.weight.medium : theme.font.weight.regular}; +`; + +const StyledChip = styled.div` + align-items: center; + display: flex; + padding: ${({ theme }) => theme.spacing(1)}; + height: 20px; + box-sizing: border-box; +`; + +type ParticipantChipVariant = 'default' | 'bold'; + +export const ParticipantChip = ({ + participant, + variant = 'default', + className, +}: { + participant: any; + variant?: ParticipantChipVariant; + className?: string; +}) => { + const { person, workspaceMember } = participant; + + const displayName = getDisplayNameFromParticipant({ + participant, + shouldUseFullName: true, + }); + + const avatarUrl = person?.avatarUrl ?? workspaceMember?.avatarUrl ?? ''; + + return ( + + {person ? ( + + ) : ( + + + {displayName} + + )} + + ); +}; 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 0174832819a2..c010da2cd1fb 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadBottomBar.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadBottomBar.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; +import { IconArrowBackUp, IconUserCircle } from 'twenty-ui'; -import { IconArrowBackUp, IconUserCircle } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; const StyledThreadBottomBar = styled.div` diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx deleted file mode 100644 index c133b5531018..000000000000 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadFetchMoreLoader.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useInView } from 'react-intersection-observer'; -import styled from '@emotion/styled'; - -import { GRAY_SCALE } from '@/ui/theme/constants/GrayScale'; - -type EmailThreadFetchMoreLoaderProps = { - loading: boolean; - onLastRowVisible: (...args: any[]) => any; -}; - -const StyledText = styled.div` - align-items: center; - box-shadow: none; - color: ${GRAY_SCALE.gray40}; - display: flex; - height: 32px; - margin-left: ${({ theme }) => theme.spacing(8)}; - padding-left: ${({ theme }) => theme.spacing(2)}; -`; - -export const EmailThreadFetchMoreLoader = ({ - loading, - onLastRowVisible, -}: EmailThreadFetchMoreLoaderProps) => { - const { ref: tbodyRef } = useInView({ - onChange: onLastRowVisible, - }); - - return ( -
- {loading && Loading more...} -
- ); -}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx index e5d53542e6e6..2dcc73c58e1a 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadHeader.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; +import { IconMail } from 'twenty-ui'; -import { IconMail } from '@/ui/display/icon'; import { Tag } from '@/ui/display/tag/components/Tag'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx index ae2552c203b1..d58e4606aafb 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageSender.tsx @@ -1,11 +1,7 @@ -import React from 'react'; import styled from '@emotion/styled'; +import { ParticipantChip } from '@/activities/components/ParticipantChip'; import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant'; -import { getDisplayNameFromParticipant } from '@/activities/emails/utils/getDisplayNameFromParticipant'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { RecordChip } from '@/object-record/components/RecordChip'; -import { Avatar } from '@/users/components/Avatar'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; const StyledEmailThreadMessageSender = styled.div` @@ -13,23 +9,6 @@ const StyledEmailThreadMessageSender = styled.div` justify-content: space-between; `; -const StyledEmailThreadMessageSenderUser = styled.div` - align-items: flex-start; - display: flex; -`; - -const StyledAvatar = styled(Avatar)` - margin: ${({ theme }) => theme.spacing(0, 1)}; -`; - -const StyledSenderName = styled.span` - color: ${({ theme }) => theme.font.color.primary}; - font-size: ${({ theme }) => theme.font.size.sm}; - font-weight: ${({ theme }) => theme.font.weight.medium}; - overflow: hidden; - text-overflow: ellipsis; -`; - const StyledThreadMessageSentAt = styled.div` align-items: flex-end; display: flex; @@ -37,10 +16,6 @@ const StyledThreadMessageSentAt = styled.div` font-size: ${({ theme }) => theme.font.size.sm}; `; -const StyledRecordChip = styled(RecordChip)` - font-weight: ${({ theme }) => theme.font.weight.medium}; -`; - type EmailThreadMessageSenderProps = { sender: EmailThreadMessageParticipant; sentAt: string; @@ -50,35 +25,9 @@ export const EmailThreadMessageSender = ({ sender, sentAt, }: EmailThreadMessageSenderProps) => { - const { person, workspaceMember } = sender; - - const displayName = getDisplayNameFromParticipant({ - participant: sender, - shouldUseFullName: true, - }); - - const avatarUrl = person?.avatarUrl ?? workspaceMember?.avatarUrl ?? ''; - return ( - - {person ? ( - - ) : ( - <> - - {displayName} - - )} - + {beautifyPastDateRelativeToNow(sentAt)} diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadNotShared.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadNotShared.tsx index 1768956ca95c..87623cb0eb4c 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadNotShared.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadNotShared.tsx @@ -1,7 +1,6 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; - -import { IconLock } from '@/ui/display/icon'; +import { IconLock } from 'twenty-ui'; const StyledContainer = styled.div` align-items: center; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx index 523645b1cb13..ce085a8e7b3d 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadPreview.tsx @@ -1,7 +1,12 @@ +import { useRef } from 'react'; import styled from '@emotion/styled'; +import { useRecoilCallback } from 'recoil'; import { EmailThreadNotShared } from '@/activities/emails/components/EmailThreadNotShared'; +import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; +import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState'; import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { GRAY_SCALE } from '@/ui/theme/constants/GrayScale'; import { Avatar } from '@/users/components/Avatar'; import { TimelineThread } from '~/generated/graphql'; @@ -73,19 +78,23 @@ const StyledReceivedAt = styled.div` padding: ${({ theme }) => theme.spacing(0, 1)}; `; +export type EmailThreadVisibility = 'metadata' | 'subject' | 'share_everything'; + type EmailThreadPreviewProps = { divider?: boolean; thread: TimelineThread; - onClick: () => void; - visibility: 'metadata' | 'subject' | 'share_everything'; }; export const EmailThreadPreview = ({ divider, thread, - onClick, - visibility, }: EmailThreadPreviewProps) => { + const cardRef = useRef(null); + + const { openEmailThread } = useEmailThread(); + + const visibility = thread.visibility as EmailThreadVisibility; + const senderNames = thread.firstParticipant.displayName + (thread?.lastTwoParticipants?.[0]?.displayName @@ -104,9 +113,39 @@ export const EmailThreadPreview = ({ false, ]; + const { isSameEventThanRightDrawerClose } = useRightDrawer(); + + const handleThreadClick = useRecoilCallback( + ({ snapshot }) => + (event: React.MouseEvent) => { + const clickJustTriggeredEmailDrawerClose = + isSameEventThanRightDrawerClose(event.nativeEvent); + + const emailThreadIdWhenEmailThreadWasClosed = snapshot + .getLoadable(emailThreadIdWhenEmailThreadWasClosedState) + .getValue(); + + const canOpen = + thread.visibility === 'share_everything' && + (!clickJustTriggeredEmailDrawerClose || + emailThreadIdWhenEmailThreadWasClosed !== thread.id); + + if (canOpen) { + openEmailThread(thread.id); + } + }, + [ + isSameEventThanRightDrawerClose, + openEmailThread, + thread.id, + thread.visibility, + ], + ); + return ( onClick()} + ref={cardRef} + onClick={(event) => handleThreadClick(event)} divider={divider} visibility={visibility} > 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 370414543363..8577d7f1a320 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -1,23 +1,18 @@ -import { useState } from 'react'; -import { useQuery } from '@apollo/client'; import styled from '@emotion/styled'; -import { useRecoilState } from 'recoil'; +import { FetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { EmailLoader } from '@/activities/emails/components/EmailLoader'; -import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging'; -import { useEmailThreadStates } from '@/activities/emails/hooks/internal/useEmailThreadStates'; -import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; +import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { H1Title, H1TitleFontColor, } from '@/ui/display/typography/components/H1Title'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { AnimatedPlaceholderEmptyContainer, @@ -27,12 +22,7 @@ import { } from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; import { Card } from '@/ui/layout/card/components/Card'; import { Section } from '@/ui/layout/section/components/Section'; -import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; -import { - GetTimelineThreadsFromPersonIdQueryVariables, - TimelineThread, - TimelineThreadsWithTotal, -} from '~/generated/graphql'; +import { TimelineThread, TimelineThreadsWithTotal } from '~/generated/graphql'; const StyledContainer = styled.div` display: flex; @@ -53,101 +43,25 @@ const StyledEmailCount = styled.span` `; export const EmailThreads = ({ - entity, + targetableObject, }: { - entity: ActivityTargetableObject; + targetableObject: ActivityTargetableObject; }) => { - const { openEmailThread } = useEmailThread(); - const { enqueueSnackBar } = useSnackBar(); - - const { getEmailThreadsPageState } = useEmailThreadStates({ - emailThreadScopeId: getScopeIdFromComponentId(entity.id), - }); - - const [emailThreadsPage, setEmailThreadsPage] = useRecoilState( - getEmailThreadsPageState(), - ); - - const [isFetchingMoreEmails, setIsFetchingMoreEmails] = useState(false); - - const [threadQuery, queryName] = - entity.targetObjectNameSingular === CoreObjectNameSingular.Person + const [query, queryName] = + targetableObject.targetObjectNameSingular === CoreObjectNameSingular.Person ? [getTimelineThreadsFromPersonId, 'getTimelineThreadsFromPersonId'] : [getTimelineThreadsFromCompanyId, 'getTimelineThreadsFromCompanyId']; - const threadQueryVariables = { - ...(entity.targetObjectNameSingular === CoreObjectNameSingular.Person - ? { personId: entity.id } - : { companyId: entity.id }), - page: 1, - pageSize: TIMELINE_THREADS_DEFAULT_PAGE_SIZE, - } as GetTimelineThreadsFromPersonIdQueryVariables; - - const { - data, - loading: firstQueryLoading, - fetchMore, - error, - } = useQuery(threadQuery, { - variables: threadQueryVariables, - }); - - const fetchMoreRecords = async () => { - if ( - emailThreadsPage.hasNextPage && - !isFetchingMoreEmails && - !firstQueryLoading - ) { - setIsFetchingMoreEmails(true); - - await fetchMore({ - variables: { - ...threadQueryVariables, - page: emailThreadsPage.pageNumber + 1, - }, - updateQuery: (prev, { fetchMoreResult }) => { - if (!fetchMoreResult?.[queryName]?.timelineThreads?.length) { - setEmailThreadsPage((emailThreadsPage) => ({ - ...emailThreadsPage, - hasNextPage: false, - })); - return { - [queryName]: { - ...prev?.[queryName], - timelineThreads: [ - ...(prev?.[queryName]?.timelineThreads ?? []), - ], - }, - }; - } - - return { - [queryName]: { - ...prev?.[queryName], - timelineThreads: [ - ...(prev?.[queryName]?.timelineThreads ?? []), - ...(fetchMoreResult?.[queryName]?.timelineThreads ?? []), - ], - }, - }; - }, - }); - setEmailThreadsPage((emailThreadsPage) => ({ - ...emailThreadsPage, - pageNumber: emailThreadsPage.pageNumber + 1, - })); - setIsFetchingMoreEmails(false); - } - }; - - if (error) { - enqueueSnackBar(error.message || 'Error loading email threads', { - variant: 'error', - }); - } + const { data, firstQueryLoading, isFetchingMore, fetchMoreRecords } = + useCustomResolver( + query, + queryName, + 'timelineThreads', + targetableObject, + TIMELINE_THREADS_DEFAULT_PAGE_SIZE, + ); - const { totalNumberOfThreads, timelineThreads }: TimelineThreadsWithTotal = - data?.[queryName] ?? []; + const { totalNumberOfThreads, timelineThreads } = data?.[queryName] ?? {}; if (firstQueryLoading) { return ; @@ -187,24 +101,12 @@ export const EmailThreads = ({ key={index} divider={index < timelineThreads.length - 1} thread={thread} - onClick={ - thread.visibility === 'share_everything' - ? () => openEmailThread(thread.id) - : () => {} - } - visibility={ - // TODO: Fix typing for visibility - thread.visibility as - | 'metadata' - | 'subject' - | 'share_everything' - } /> ))} )} - diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/__tests__/useEmailThread.test.tsx b/packages/twenty-front/src/modules/activities/emails/hooks/__tests__/useEmailThread.test.tsx new file mode 100644 index 000000000000..4dae422ca2fd --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/hooks/__tests__/useEmailThread.test.tsx @@ -0,0 +1,69 @@ +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useRecoilState, useRecoilValue } from 'recoil'; + +import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; +import { viewableEmailThreadIdState } from '@/activities/emails/states/viewableEmailThreadIdState'; +import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; + +const viewableEmailThreadId = '1234'; + +describe('useEmailThread', () => { + it('should open email thread', () => { + const { result } = renderHook( + () => { + const emailThread = useEmailThread(); + const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState); + const viewableEmailThreadId = useRecoilValue( + viewableEmailThreadIdState, + ); + + return { ...emailThread, isRightDrawerOpen, viewableEmailThreadId }; + }, + { wrapper: RecoilRoot }, + ); + + expect(result.current.isRightDrawerOpen).toBe(false); + expect(result.current.viewableEmailThreadId).toBeNull(); + + act(() => { + result.current.openEmailThread(viewableEmailThreadId); + }); + + expect(result.current.isRightDrawerOpen).toBe(true); + expect(result.current.viewableEmailThreadId).toBe(viewableEmailThreadId); + }); + + it('should close email thread if trying to open the same thread id', () => { + const { result } = renderHook( + () => { + const emailThread = useEmailThread(); + const [isRightDrawerOpen, setIsRightDrawerOpen] = useRecoilState( + isRightDrawerOpenState, + ); + const [viewableEmailThreadId, setViewableEmailThreadId] = + useRecoilState(viewableEmailThreadIdState); + + return { + ...emailThread, + isRightDrawerOpen, + viewableEmailThreadId, + setIsRightDrawerOpen, + setViewableEmailThreadId, + }; + }, + { wrapper: RecoilRoot }, + ); + + act(() => { + result.current.setIsRightDrawerOpen(true); + result.current.setViewableEmailThreadId(viewableEmailThreadId); + }); + + act(() => { + result.current.openEmailThread(viewableEmailThreadId); + }); + + expect(result.current.isRightDrawerOpen).toBe(false); + expect(result.current.viewableEmailThreadId).toBeNull(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts b/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts deleted file mode 100644 index dc40a314dfb5..000000000000 --- a/packages/twenty-front/src/modules/activities/emails/hooks/internal/useEmailThreadStates.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { emailThreadsPageStateScopeMap } from '@/activities/emails/state/emailThreadsPageStateScopeMap'; -import { TabListScopeInternalContext } from '@/ui/layout/tab/scopes/scope-internal-context/TabListScopeInternalContext'; -import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; -import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; - -type useEmailThreadStatesProps = { - emailThreadScopeId?: string; -}; - -export const useEmailThreadStates = ({ - emailThreadScopeId, -}: useEmailThreadStatesProps) => { - const scopeId = useAvailableScopeIdOrThrow( - TabListScopeInternalContext, - emailThreadScopeId, - ); - - return { - scopeId, - getEmailThreadsPageState: getState(emailThreadsPageStateScopeMap, scopeId), - }; -}; diff --git a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts index a0e9bbc857c8..a0757a18af57 100644 --- a/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts +++ b/packages/twenty-front/src/modules/activities/emails/hooks/useEmailThread.ts @@ -1,22 +1,36 @@ -import { useRecoilState } from 'recoil'; +import { useRecoilCallback } from 'recoil'; import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer'; -import { viewableEmailThreadIdState } from '@/activities/emails/state/viewableEmailThreadIdState'; +import { viewableEmailThreadIdState } from '@/activities/emails/states/viewableEmailThreadIdState'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; export const useEmailThread = () => { - const [, setViewableEmailThreadId] = useRecoilState( - viewableEmailThreadIdState, - ); - + const { closeRightDrawer } = useRightDrawer(); const openEmailThredRightDrawer = useOpenEmailThreadRightDrawer(); - const openEmailThread = (threadId: string) => { - openEmailThredRightDrawer(); + const openEmailThread = useRecoilCallback( + ({ snapshot, set }) => + (threadId: string) => { + const isRightDrawerOpen = snapshot + .getLoadable(isRightDrawerOpenState) + .getValue(); + + const viewableEmailThreadId = snapshot + .getLoadable(viewableEmailThreadIdState) + .getValue(); - setViewableEmailThreadId(threadId); - }; + if (isRightDrawerOpen && viewableEmailThreadId === threadId) { + set(viewableEmailThreadIdState, null); + closeRightDrawer(); + return; + } + + openEmailThredRightDrawer(); + set(viewableEmailThreadIdState, threadId); + }, + [closeRightDrawer, openEmailThredRightDrawer], + ); - return { - openEmailThread, - }; + return { openEmailThread }; }; diff --git a/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts b/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts new file mode 100644 index 000000000000..9b3c3ba70fde --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client'; + +import { getTimelineThreadsFromCompanyId } from '../getTimelineThreadsFromCompanyId'; + +jest.mock('@apollo/client', () => ({ + gql: jest.fn().mockImplementation((strings) => { + return strings.map((str: string) => str.trim()).join(' '); + }), +})); + +describe('getTimelineThreadsFromCompanyId query', () => { + test('should construct the query correctly', () => { + expect(gql).toHaveBeenCalled(); + expect(getTimelineThreadsFromCompanyId).toBeDefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromPersonId.test.ts b/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromPersonId.test.ts new file mode 100644 index 000000000000..66862b7288ee --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromPersonId.test.ts @@ -0,0 +1,16 @@ +import { gql } from '@apollo/client'; + +import { getTimelineThreadsFromPersonId } from '../getTimelineThreadsFromPersonId'; + +jest.mock('@apollo/client', () => ({ + gql: jest.fn().mockImplementation((strings) => { + return strings.map((str: string) => str.trim()).join(' '); + }), +})); + +describe('getTimelineThreadsFromPersonId query', () => { + test('should construct the query correctly', () => { + expect(gql).toHaveBeenCalled(); + expect(getTimelineThreadsFromPersonId).toBeDefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts index e0df0e6d7e96..589905550c7d 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts +++ b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts @@ -4,7 +4,7 @@ import { timelineThreadWithTotalFragment } from '@/activities/emails/queries/fra export const getTimelineThreadsFromCompanyId = gql` query GetTimelineThreadsFromCompanyId( - $companyId: ID! + $companyId: UUID! $page: Int! $pageSize: Int! ) { diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts index 6bad39f9b6d7..84cd7053791e 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts +++ b/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts @@ -4,7 +4,7 @@ import { timelineThreadWithTotalFragment } from '@/activities/emails/queries/fra export const getTimelineThreadsFromPersonId = gql` query GetTimelineThreadsFromPersonId( - $personId: ID! + $personId: UUID! $page: Int! $pageSize: Int! ) { 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 a59741d15de4..e80b0a4e9104 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 @@ -1,11 +1,14 @@ -import React from 'react'; import styled from '@emotion/styled'; +import { useRecoilCallback } from 'recoil'; +import { FetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { EmailLoader } from '@/activities/emails/components/EmailLoader'; -import { EmailThreadFetchMoreLoader } from '@/activities/emails/components/EmailThreadFetchMoreLoader'; import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader'; import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage'; import { useRightDrawerEmailThread } from '@/activities/emails/right-drawer/hooks/useRightDrawerEmailThread'; +import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState'; +import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; +import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; const StyledContainer = styled.div` box-sizing: border-box; @@ -21,6 +24,22 @@ export const RightDrawerEmailThread = () => { const { thread, messages, fetchMoreMessages, loading } = useRightDrawerEmailThread(); + const { useRegisterClickOutsideListenerCallback } = useClickOutsideListener( + RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID, + ); + + useRegisterClickOutsideListenerCallback({ + callbackId: + 'EmailThreadClickOutsideCallBack-' + thread.id ?? 'no-thread-id', + callbackFunction: useRecoilCallback( + ({ set }) => + () => { + set(emailThreadIdWhenEmailThreadWasClosedState, thread.id); + }, + [thread], + ), + }); + if (!thread) { return null; } @@ -43,7 +62,7 @@ export const RightDrawerEmailThread = () => { sentAt={message.receivedAt} /> ))} - diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar.tsx deleted file mode 100644 index 29e71fe6e127..000000000000 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import styled from '@emotion/styled'; - -import { StyledRightDrawerTopBar } from '@/ui/layout/right-drawer/components/StyledRightDrawerTopBar'; -import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; - -import { RightDrawerTopBarCloseButton } from '../../../../ui/layout/right-drawer/components/RightDrawerTopBarCloseButton'; -import { RightDrawerTopBarExpandButton } from '../../../../ui/layout/right-drawer/components/RightDrawerTopBarExpandButton'; - -const StyledTopBarWrapper = styled.div` - display: flex; -`; - -export const RightDrawerEmailThreadTopBar = () => { - const isMobile = useIsMobile(); - - return ( - - - - {!isMobile && } - - - ); -}; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/__stories__/RightDrawerEmailThreadTopBar.stories.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/__stories__/RightDrawerEmailThreadTopBar.stories.tsx deleted file mode 100644 index ebbfc80dcb92..000000000000 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/__stories__/RightDrawerEmailThreadTopBar.stories.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { RightDrawerEmailThreadTopBar } from '@/activities/emails/right-drawer/components/RightDrawerEmailThreadTopBar'; -import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; -import { graphqlMocks } from '~/testing/graphqlMocks'; - -const meta: Meta = { - title: 'Modules/Activities/Emails/RightDrawer/RightDrawerEmailThreadTopBar', - component: RightDrawerEmailThreadTopBar, - decorators: [ - (Story) => ( -
- -
- ), - ComponentDecorator, - ], - parameters: { - msw: graphqlMocks, - }, -}; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts new file mode 100644 index 000000000000..6e77fc50907d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useOpenEmailThreadRightDrawer.test.ts @@ -0,0 +1,35 @@ +import { act } from 'react-dom/test-utils'; +import { renderHook } from '@testing-library/react'; + +import { useOpenEmailThreadRightDrawer } from '@/activities/emails/right-drawer/hooks/useOpenEmailThreadRightDrawer'; +import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; + +const mockOpenRightDrawer = jest.fn(); +const mockSetHotkeyScope = jest.fn(); + +jest.mock('@/ui/layout/right-drawer/hooks/useRightDrawer', () => ({ + useRightDrawer: () => ({ + openRightDrawer: mockOpenRightDrawer, + }), +})); + +jest.mock('@/ui/utilities/hotkey/hooks/useSetHotkeyScope', () => ({ + useSetHotkeyScope: () => mockSetHotkeyScope, +})); + +test('useOpenEmailThreadRightDrawer opens the email thread right drawer', () => { + const { result } = renderHook(() => useOpenEmailThreadRightDrawer()); + + act(() => { + result.current(); + }); + + expect(mockSetHotkeyScope).toHaveBeenCalledWith( + RightDrawerHotkeyScope.RightDrawer, + { goto: false }, + ); + expect(mockOpenRightDrawer).toHaveBeenCalledWith( + RightDrawerPages.ViewEmailThread, + ); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx new file mode 100644 index 000000000000..63f1b09b06de --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/__tests__/useRightDrawerEmailThread.test.tsx @@ -0,0 +1,40 @@ +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; + +import { useRightDrawerEmailThread } from '../useRightDrawerEmailThread'; + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + __esModule: true, + useFindManyRecords: jest.fn(), +})); + +describe('useRightDrawerEmailThread', () => { + it('should return correct values', async () => { + const mockMessages = [ + { id: '1', text: 'Message 1' }, + { id: '2', text: 'Message 2' }, + ]; + const mockFetchMoreRecords = jest.fn(); + (useFindManyRecords as jest.Mock).mockReturnValue({ + records: mockMessages, + loading: false, + fetchMoreRecords: mockFetchMoreRecords, + }); + + const { result } = renderHook(() => useRightDrawerEmailThread(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + expect(result.current.thread).toBeDefined(); + expect(result.current.messages).toEqual(mockMessages); + expect(result.current.loading).toBeFalsy(); + expect(result.current.fetchMoreMessages).toBeInstanceOf(Function); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts index 1dbce6e00646..a524679fdb34 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/hooks/useRightDrawerEmailThread.ts @@ -3,7 +3,7 @@ import { useApolloClient } from '@apollo/client'; import gql from 'graphql-tag'; import { useRecoilValue } from 'recoil'; -import { viewableEmailThreadIdState } from '@/activities/emails/state/viewableEmailThreadIdState'; +import { viewableEmailThreadIdState } from '@/activities/emails/states/viewableEmailThreadIdState'; import { EmailThreadMessage as EmailThreadMessageType } from '@/activities/emails/types/EmailThreadMessage'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -40,7 +40,6 @@ export const useRightDrawerEmailThread = () => { receivedAt: 'AscNullsLast', }, skip: !viewableEmailThreadId, - useRecordsWithoutConnection: true, }); const fetchMoreMessages = useCallback(() => { diff --git a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts b/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts deleted file mode 100644 index f7761acc793b..000000000000 --- a/packages/twenty-front/src/modules/activities/emails/state/emailThreadsPageStateScopeMap.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export type EmailThreadsPageType = { - pageNumber: number; - hasNextPage: boolean; -}; - -export const emailThreadsPageStateScopeMap = - createStateScopeMap({ - key: 'emailThreadsPageStateScopeMap', - defaultValue: { pageNumber: 1, hasNextPage: true }, - }); diff --git a/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadIdState.ts b/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadIdState.ts deleted file mode 100644 index 73b4a3b89a46..000000000000 --- a/packages/twenty-front/src/modules/activities/emails/state/viewableEmailThreadIdState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { atom } from 'recoil'; - -export const viewableEmailThreadIdState = atom({ - key: 'viewableEmailThreadIdState', - default: null, -}); diff --git a/packages/twenty-front/src/modules/activities/emails/states/lastViewableEmailThreadIdState.ts b/packages/twenty-front/src/modules/activities/emails/states/lastViewableEmailThreadIdState.ts new file mode 100644 index 000000000000..4e90b960566d --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/states/lastViewableEmailThreadIdState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +export const emailThreadIdWhenEmailThreadWasClosedState = createState< + string | null +>({ + key: 'emailThreadIdWhenEmailThreadWasClosedState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/activities/emails/states/viewableEmailThreadIdState.ts b/packages/twenty-front/src/modules/activities/emails/states/viewableEmailThreadIdState.ts new file mode 100644 index 000000000000..494ec3d9cb63 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/states/viewableEmailThreadIdState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const viewableEmailThreadIdState = createState({ + key: 'viewableEmailThreadIdState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts b/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts new file mode 100644 index 000000000000..e7647d869483 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/emails/utils/__tests__/getDisplayNameFromParticipant.test.ts @@ -0,0 +1,100 @@ +import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant'; + +import { getDisplayNameFromParticipant } from '../getDisplayNameFromParticipant'; + +describe('getDisplayNameFromParticipant', () => { + const participantWithName: EmailThreadMessageParticipant = { + displayName: '', + handle: '', + role: 'from', + person: { + id: '1', + createdAt: '', + updatedAt: '', + deletedAt: null, + name: { + firstName: 'John', + lastName: 'Doe', + }, + avatarUrl: '', + jobTitle: '', + linkedinLink: { + url: '', + label: '', + }, + xLink: { + url: '', + label: '', + }, + city: '', + email: '', + phone: '', + companyId: '', + }, + workspaceMember: { + id: '1', + name: { + firstName: 'Jane', + lastName: 'Smith', + }, + locale: '', + createdAt: '', + updatedAt: '', + userEmail: '', + userId: '', + }, + }; + + const participantWithHandle: any = { + displayName: '', + handle: 'user_handle', + role: 'from', + }; + + const participantWithDisplayName: any = { + displayName: 'User123', + handle: '', + role: 'from', + }; + + const participantWithoutInfo: any = { + displayName: '', + handle: '', + role: 'from', + }; + + it('should return full name when shouldUseFullName is true', () => { + expect( + getDisplayNameFromParticipant({ + participant: participantWithName, + shouldUseFullName: true, + }), + ).toBe('John Doe'); + }); + + it('should return first name when shouldUseFullName is false', () => { + expect( + getDisplayNameFromParticipant({ participant: participantWithName }), + ).toBe('John'); + }); + + it('should return displayName if it is a non-empty string', () => { + expect( + getDisplayNameFromParticipant({ + participant: participantWithDisplayName, + }), + ).toBe('User123'); + }); + + it('should return handle if displayName is not available', () => { + expect( + getDisplayNameFromParticipant({ participant: participantWithHandle }), + ).toBe('user_handle'); + }); + + it('should return Unknown if no suitable information is available', () => { + expect( + getDisplayNameFromParticipant({ participant: participantWithoutInfo }), + ).toBe('Unknown'); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts b/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts index 8735711c21b8..ca1e63ee2aa2 100644 --- a/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts +++ b/packages/twenty-front/src/modules/activities/emails/utils/getDisplayNameFromParticipant.ts @@ -1,4 +1,7 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { EmailThreadMessageParticipant } from '@/activities/emails/types/EmailThreadMessageParticipant'; +import { isDefined } from '~/utils/isDefined'; export const getDisplayNameFromParticipant = ({ participant, @@ -7,14 +10,14 @@ export const getDisplayNameFromParticipant = ({ participant: EmailThreadMessageParticipant; shouldUseFullName?: boolean; }) => { - if (participant.person) { + if (isDefined(participant.person)) { return ( `${participant.person?.name?.firstName}` + (shouldUseFullName ? ` ${participant.person?.name?.lastName}` : '') ); } - if (participant.workspaceMember) { + if (isDefined(participant.workspaceMember)) { return ( participant.workspaceMember?.name?.firstName + (shouldUseFullName @@ -23,11 +26,11 @@ export const getDisplayNameFromParticipant = ({ ); } - if (participant.displayName) { + if (isNonEmptyString(participant.displayName)) { return participant.displayName; } - if (participant.handle) { + if (isNonEmptyString(participant.handle)) { return participant.handle; } diff --git a/packages/twenty-front/src/modules/activities/events/components/EventList.tsx b/packages/twenty-front/src/modules/activities/events/components/EventList.tsx new file mode 100644 index 000000000000..92219b4e8430 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventList.tsx @@ -0,0 +1,56 @@ +import { ReactElement } from 'react'; +import styled from '@emotion/styled'; + +import { EventsGroup } from '@/activities/events/components/EventsGroup'; +import { Event } from '@/activities/events/types/Event'; +import { groupEventsByMonth } from '@/activities/events/utils/groupEventsByMonth'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; + +type EventListProps = { + targetableObject: ActivityTargetableObject; + title: string; + events: Event[]; + button?: ReactElement | false; +}; + +const StyledTimelineContainer = styled.div` + align-items: center; + align-self: stretch; + + display: flex; + flex: 1 0 0; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(1)}; + justify-content: flex-start; + + padding: ${({ theme }) => theme.spacing(4)}; + width: calc(100% - ${({ theme }) => theme.spacing(8)}); +`; + +export const EventList = ({ events, targetableObject }: EventListProps) => { + const groupedEvents = groupEventsByMonth(events); + + return ( + + + {groupedEvents.map((group, index) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx b/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx new file mode 100644 index 000000000000..581d9120c896 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventRow.tsx @@ -0,0 +1,199 @@ +import { Tooltip } from 'react-tooltip'; +import styled from '@emotion/styled'; +import { IconCirclePlus, IconEditCircle, IconFocusCentered } from 'twenty-ui'; + +import { EventUpdateProperty } from '@/activities/events/components/EventUpdateProperty'; +import { Event } from '@/activities/events/types/Event'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { + beautifyExactDateTime, + beautifyPastDateRelativeToNow, +} from '~/utils/date-utils'; + +const StyledIconContainer = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + user-select: none; + height: 16px; + margin: 5px; + justify-content: center; + text-decoration-line: underline; + width: 16px; + z-index: 2; +`; + +const StyledActionName = styled.span` + overflow: hidden; + flex: none; + white-space: nowrap; +`; + +const StyledItemContainer = styled.div` + align-content: center; + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + flex: 1; + gap: ${({ theme }) => theme.spacing(1)}; + span { + color: ${({ theme }) => theme.font.color.secondary}; + } + overflow: hidden; +`; + +const StyledItemTitleContainer = styled.div` + display: flex; + flex: 1; + flex-flow: row ${() => (useIsMobile() ? 'wrap' : 'nowrap')}; + gap: ${({ theme }) => theme.spacing(1)}; + overflow: hidden; +`; + +const StyledItemAuthorText = styled.span` + display: flex; + color: ${({ theme }) => theme.font.color.primary}; + gap: ${({ theme }) => theme.spacing(1)}; + white-space: nowrap; +`; + +const StyledItemTitle = styled.span` + display: flex; + flex-flow: row nowrap; + overflow: hidden; + white-space: nowrap; +`; + +const StyledItemTitleDate = styled.div` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: flex-end; + margin-left: auto; +`; + +const StyledVerticalLineContainer = styled.div` + align-items: center; + align-self: stretch; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + justify-content: center; + width: 26px; + z-index: 2; +`; + +const StyledVerticalLine = styled.div` + align-self: stretch; + background: ${({ theme }) => theme.border.color.light}; + flex-shrink: 0; + width: 2px; +`; + +const StyledTooltip = styled(Tooltip)` + background-color: ${({ theme }) => theme.background.primary}; + + box-shadow: 0px 2px 4px 3px + ${({ theme }) => theme.background.transparent.light}; + + box-shadow: 2px 4px 16px 6px + ${({ theme }) => theme.background.transparent.light}; + + color: ${({ theme }) => theme.font.color.primary}; + + opacity: 1; + padding: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledTimelineItemContainer = styled.div<{ isGap?: boolean }>` + align-items: center; + align-self: stretch; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; + height: ${({ isGap, theme }) => + isGap ? (useIsMobile() ? theme.spacing(6) : theme.spacing(3)) : 'auto'}; + overflow: hidden; + white-space: nowrap; +`; + +type EventRowProps = { + targetableObject: ActivityTargetableObject; + isLastEvent?: boolean; + event: Event; +}; + +export const EventRow = ({ + isLastEvent, + event, + targetableObject, +}: EventRowProps) => { + const beautifiedCreatedAt = beautifyPastDateRelativeToNow(event.createdAt); + const exactCreatedAt = beautifyExactDateTime(event.createdAt); + + const properties = JSON.parse(event.properties); + const diff: Record = properties?.diff; + + const isEventType = (type: 'created' | 'updated') => { + return ( + event.name === type + '.' + targetableObject.targetObjectNameSingular + ); + }; + + return ( + <> + + + {isEventType('created') && } + {isEventType('updated') && } + {!isEventType('created') && !isEventType('updated') && ( + + )} + + + + + {event.workspaceMember?.name.firstName}{' '} + {event.workspaceMember?.name.lastName} + + + {isEventType('created') && 'created'} + {isEventType('updated') && 'updated'} + {!isEventType('created') && !isEventType('updated') && event.name} + + + {isEventType('created') && + `a new ${targetableObject.targetObjectNameSingular}`} + {isEventType('updated') && + Object.entries(diff).map(([key, value]) => ( + + ))} + {!isEventType('created') && + !isEventType('updated') && + JSON.stringify(diff)} + + + + {beautifiedCreatedAt} + + + + + {!isLastEvent && ( + + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx b/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx new file mode 100644 index 000000000000..cbeb157e2db3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventUpdateProperty.tsx @@ -0,0 +1,31 @@ +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconArrowRight } from 'twenty-ui'; + +type EventUpdatePropertyProps = { + propertyName: string; + after?: string; +}; + +const StyledContainer = styled.div` + display: flex; + margin-right: ${({ theme }) => theme.spacing(1)}; + gap: ${({ theme }) => theme.spacing(1)}; + white-space: nowrap; +`; + +const StyledPropertyName = styled.div``; + +export const EventUpdateProperty = ({ + propertyName, + after, +}: EventUpdatePropertyProps) => { + const theme = useTheme(); + return ( + + {propertyName} + + {after} + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/Events.tsx b/packages/twenty-front/src/modules/activities/events/components/Events.tsx new file mode 100644 index 000000000000..5caccae98c59 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/Events.tsx @@ -0,0 +1,60 @@ +import styled from '@emotion/styled'; +import { isNonEmptyArray } from '@sniptt/guards'; + +import { EventList } from '@/activities/events/components/EventList'; +import { useEvents } from '@/activities/events/hooks/useEvents'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; +import { + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, +} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; + +const StyledMainContainer = styled.div` + align-items: flex-start; + align-self: stretch; + border-top: ${({ theme }) => + useIsMobile() ? `1px solid ${theme.border.color.medium}` : 'none'}; + display: flex; + flex-direction: column; + height: 100%; + + justify-content: center; +`; + +export const Events = ({ + targetableObject, +}: { + targetableObject: ActivityTargetableObject; +}) => { + const { events } = useEvents(targetableObject); + + if (!isNonEmptyArray(events)) { + return ( + + + + + No Events + + + There are no events associated with this record.{' '} + + + + ); + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx b/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx new file mode 100644 index 000000000000..091e9913e68e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/components/EventsGroup.tsx @@ -0,0 +1,81 @@ +import styled from '@emotion/styled'; + +import { EventRow } from '@/activities/events/components/EventRow'; +import { EventGroup } from '@/activities/events/utils/groupEventsByMonth'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +type EventsGroupProps = { + group: EventGroup; + month: string; + year?: number; + targetableObject: ActivityTargetableObject; +}; + +const StyledActivityGroup = styled.div` + display: flex; + flex-flow: column; + gap: ${({ theme }) => theme.spacing(4)}; + margin-bottom: ${({ theme }) => theme.spacing(4)}; + width: 100%; +`; + +const StyledActivityGroupContainer = styled.div` + padding-bottom: ${({ theme }) => theme.spacing(2)}; + padding-top: ${({ theme }) => theme.spacing(2)}; + position: relative; +`; + +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}; + display: flex; + flex-direction: column; + height: 100%; + justify-content: center; + position: absolute; + top: 0; + width: 24px; +`; + +const StyledMonthSeperator = styled.div` + align-items: center; + align-self: stretch; + color: ${({ theme }) => theme.font.color.light}; + display: flex; + gap: ${({ theme }) => theme.spacing(4)}; +`; +const StyledMonthSeperatorLine = styled.div` + background: ${({ theme }) => theme.border.color.light}; + border-radius: 50px; + flex: 1 0 0; + height: 1px; +`; + +export const EventsGroup = ({ + group, + month, + year, + targetableObject, +}: EventsGroupProps) => { + return ( + + + {month} {year} + + + + + {group.items.map((event, index) => ( + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts b/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts new file mode 100644 index 000000000000..25c6d5bf1209 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/hooks/__tests__/useEvents.test.ts @@ -0,0 +1,91 @@ +import { renderHook } from '@testing-library/react'; + +import { useEvents } from '@/activities/events/hooks/useEvents'; + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + useFindManyRecords: jest.fn(), +})); + +describe('useEvent', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('fetches events correctly for a given targetableObject', () => { + const mockEvents = [ + { + __typename: 'Event', + id: '166ec73f-26b1-4934-bb3b-c86c8513b99b', + opportunityId: null, + opportunity: null, + personId: null, + person: null, + company: { + __typename: 'Company', + address: 'Paris', + linkedinLink: { + __typename: 'Link', + label: '', + url: '', + }, + xLink: { + __typename: 'Link', + label: '', + url: '', + }, + position: 4, + domainName: 'microsoft.com', + employees: null, + createdAt: '2024-03-21T16:01:41.809Z', + annualRecurringRevenue: { + __typename: 'Currency', + amountMicros: 100000000, + currencyCode: 'USD', + }, + idealCustomerProfile: false, + accountOwnerId: null, + updatedAt: '2024-03-22T08:28:44.812Z', + name: 'Microsoft', + id: '460b6fb1-ed89-413a-b31a-962986e67bb4', + }, + workspaceMember: { + __typename: 'WorkspaceMember', + locale: 'en', + avatarUrl: '', + updatedAt: '2024-03-21T16:01:41.839Z', + name: { + __typename: 'FullName', + firstName: 'Tim', + lastName: 'Apple', + }, + id: '20202020-0687-4c41-b707-ed1bfca972a7', + userEmail: 'tim@apple.dev', + colorScheme: 'Light', + createdAt: '2024-03-21T16:01:41.839Z', + userId: '20202020-9e3b-46d4-a556-88b9ddc2b034', + }, + workspaceMemberId: '20202020-0687-4c41-b707-ed1bfca972a7', + createdAt: '2024-03-22T08:28:44.830Z', + name: 'updated.company', + companyId: '460b6fb1-ed89-413a-b31a-962986e67bb4', + properties: '{"diff": {"address": {"after": "Paris", "before": ""}}}', + updatedAt: '2024-03-22T08:28:44.830Z', + }, + ]; + const mockTargetableObject = { + id: '1', + targetObjectNameSingular: 'Opportunity', + }; + + const useFindManyRecordsMock = jest.requireMock( + '@/object-record/hooks/useFindManyRecords', + ); + useFindManyRecordsMock.useFindManyRecords.mockReturnValue({ + records: mockEvents, + }); + + const { result } = renderHook(() => useEvents(mockTargetableObject)); + + expect(result.current.events).toEqual(mockEvents); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx b/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx new file mode 100644 index 000000000000..9e5cbecc3e19 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/hooks/useEvents.tsx @@ -0,0 +1,28 @@ +import { Event } from '@/activities/events/types/Event'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; + +// do we need to test this? +export const useEvents = (targetableObject: ActivityTargetableObject) => { + const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); + + const { records: events } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Event, + filter: { + [targetableObjectFieldIdName]: { + eq: targetableObject.id, + }, + }, + orderBy: { + createdAt: 'DescNullsFirst', + }, + }); + + return { + events: events as Event[], + }; +}; diff --git a/packages/twenty-front/src/modules/activities/events/types/Event.ts b/packages/twenty-front/src/modules/activities/events/types/Event.ts new file mode 100644 index 000000000000..c39ceecd2bb4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/types/Event.ts @@ -0,0 +1,15 @@ +import { WorkspaceMember } from '~/generated/graphql'; + +export type Event = { + id: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + opportunityId: string | null; + companyId: string | null; + personId: string | null; + workspaceMemberId: string; + workspaceMember: WorkspaceMember; + properties: any; + name: string; +}; diff --git a/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts b/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts new file mode 100644 index 000000000000..917ece6f6fb4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/utils/__tests__/groupEventsByMonth.test.ts @@ -0,0 +1,19 @@ +import { mockedEvents } from '~/testing/mock-data/events'; + +import { groupEventsByMonth } from '../groupEventsByMonth'; + +describe('groupEventsByMonth', () => { + it('should group activities by month', () => { + const grouped = groupEventsByMonth(mockedEvents as unknown as Event[]); + + expect(grouped).toHaveLength(2); + expect(grouped[0].items).toHaveLength(1); + expect(grouped[1].items).toHaveLength(1); + + expect(grouped[0].year).toBe(new Date().getFullYear()); + expect(grouped[1].year).toBe(2023); + + expect(grouped[0].month).toBe(new Date().getMonth()); + expect(grouped[1].month).toBe(3); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts b/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts new file mode 100644 index 000000000000..316bd25e858e --- /dev/null +++ b/packages/twenty-front/src/modules/activities/events/utils/groupEventsByMonth.ts @@ -0,0 +1,33 @@ +import { Event } from '@/activities/events/types/Event'; +import { isDefined } from '~/utils/isDefined'; + +export type EventGroup = { + month: number; + year: number; + items: Event[]; +}; + +export const groupEventsByMonth = (events: Event[]) => { + const acitivityGroups: EventGroup[] = []; + + for (const event of events) { + const d = new Date(event.createdAt); + const month = d.getMonth(); + const year = d.getFullYear(); + + const matchingGroup = acitivityGroups.find( + (x) => x.year === year && x.month === month, + ); + if (isDefined(matchingGroup)) { + matchingGroup.items.push(event); + } else { + acitivityGroups.push({ + year, + month, + items: [event], + }); + } + } + + return acitivityGroups.sort((a, b) => b.year - a.year || b.month - a.month); +}; 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 33aebff64e55..884c43c812ee 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentDropdown.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentDropdown.tsx @@ -1,4 +1,5 @@ -import { IconDotsVertical, IconDownload, IconTrash } from '@/ui/display/icon'; +import { IconDotsVertical, IconDownload, IconTrash } 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'; diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentIcon.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentIcon.tsx index aaf8232cbdfd..5061ab65fe01 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentIcon.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentIcon.tsx @@ -1,7 +1,5 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; - -import { AttachmentType } from '@/activities/files/types/Attachment'; import { IconFile, IconFileText, @@ -11,7 +9,9 @@ import { IconPresentation, IconTable, IconVideo, -} from '@/ui/display/icon'; +} from 'twenty-ui'; + +import { AttachmentType } from '@/activities/files/types/Attachment'; import { IconComponent } from '@/ui/display/icon/types/IconComponent'; const StyledIconContainer = styled.div<{ background: string }>` diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx index 4aac69cb7536..30485c7f46d3 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentRow.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconCalendar } from 'twenty-ui'; import { AttachmentDropdown } from '@/activities/files/components/AttachmentDropdown'; import { AttachmentIcon } from '@/activities/files/components/AttachmentIcon'; @@ -12,7 +13,6 @@ import { FieldContext, GenericFieldContextType, } from '@/object-record/record-field/contexts/FieldContext'; -import { IconCalendar } from '@/ui/display/icon'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { formatToHumanReadableDate } from '~/utils'; 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 d13871b40916..aacc0ec6ad3f 100644 --- a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx @@ -1,13 +1,13 @@ import { ChangeEvent, useRef, useState } from 'react'; import styled from '@emotion/styled'; import { isNonEmptyArray } from '@sniptt/guards'; +import { IconPlus } from 'twenty-ui'; import { AttachmentList } from '@/activities/files/components/AttachmentList'; 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 { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { @@ -16,6 +16,7 @@ import { AnimatedPlaceholderEmptyTextContainer, AnimatedPlaceholderEmptyTitle, } from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; +import { isDefined } from '~/utils/isDefined'; const StyledAttachmentsContainer = styled.div` display: flex; @@ -46,7 +47,7 @@ export const Attachments = ({ const [isDraggingFile, setIsDraggingFile] = useState(false); const handleFileChange = (e: ChangeEvent) => { - if (e.target.files) onUploadFile?.(e.target.files[0]); + if (isDefined(e.target.files)) onUploadFile?.(e.target.files[0]); }; const handleUploadFileClick = () => { diff --git a/packages/twenty-front/src/modules/activities/files/components/DropZone.tsx b/packages/twenty-front/src/modules/activities/files/components/DropZone.tsx index e1068d48951d..7fa8cf1abd28 100644 --- a/packages/twenty-front/src/modules/activities/files/components/DropZone.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/DropZone.tsx @@ -1,9 +1,9 @@ import { useDropzone } from 'react-dropzone'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconUpload } from 'twenty-ui'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; -import { IconUpload } from '@/ui/display/icon'; const StyledContainer = styled.div` align-items: center; diff --git a/packages/twenty-front/src/modules/activities/files/hooks/__tests__/useAttachments.test.ts b/packages/twenty-front/src/modules/activities/files/hooks/__tests__/useAttachments.test.ts new file mode 100644 index 000000000000..5d582322ba00 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/files/hooks/__tests__/useAttachments.test.ts @@ -0,0 +1,51 @@ +import { renderHook } from '@testing-library/react'; + +import { useAttachments } from '../useAttachments'; + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + useFindManyRecords: jest.fn(), +})); + +describe('useAttachments', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('fetches attachments correctly for a given targetableObject', () => { + const mockAttachments = [ + { id: '1', name: 'Attachment 1' }, + { id: 2, name: 'Attachment 2' }, + ]; + const mockTargetableObject = { + id: '1', + targetObjectNameSingular: 'SomeObject', + }; + + const useFindManyRecordsMock = jest.requireMock( + '@/object-record/hooks/useFindManyRecords', + ); + useFindManyRecordsMock.useFindManyRecords.mockReturnValue({ + records: mockAttachments, + }); + + const { result } = renderHook(() => useAttachments(mockTargetableObject)); + + expect(result.current.attachments).toEqual(mockAttachments); + }); + + it('handles case when there are no attachments', () => { + const mockTargetableObject = { + id: '1', + targetObjectNameSingular: 'SomeObject', + }; + + const useFindManyRecordsMock = jest.requireMock( + '@/object-record/hooks/useFindManyRecords', + ); + useFindManyRecordsMock.useFindManyRecords.mockReturnValue({ records: [] }); + + const { result } = renderHook(() => useAttachments(mockTargetableObject)); + + expect(result.current.attachments).toEqual([]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx b/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx index b8d71ec8d847..2d8539889d27 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useAttachments.tsx @@ -1,6 +1,6 @@ import { Attachment } from '@/activities/files/types/Attachment'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -10,7 +10,7 @@ export const useAttachments = (targetableObject: ActivityTargetableObject) => { nameSingular: targetableObject.targetObjectNameSingular, }); - const { records: attachments } = useFindManyRecords({ + const { records: attachments } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Attachment, filter: { [targetableObjectFieldIdName]: { @@ -23,6 +23,6 @@ export const useAttachments = (targetableObject: ActivityTargetableObject) => { }); return { - attachments: attachments as Attachment[], + attachments, }; }; 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 1a0cceee7a5d..d68645fe1310 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx @@ -2,7 +2,7 @@ import { useRecoilValue } from 'recoil'; import { getFileType } from '@/activities/files/utils/getFileType'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; import { Attachment } from '@/attachments/types/Attachment'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; @@ -45,6 +45,8 @@ export const useUploadAttachmentFile = () => { fullPath: attachmentUrl, type: getFileType(file.name), [targetableObjectFieldIdName]: targetableObject.id, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), } as Partial; await createOneAttachment(attachmentToCreate); diff --git a/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts b/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts index 7c1c01eeb45f..b25fae0e7c37 100644 --- a/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts +++ b/packages/twenty-front/src/modules/activities/files/utils/__tests__/downloadFile.test.ts @@ -28,7 +28,7 @@ describe.skip('downloadFile', () => { const link = document.querySelector( 'a[href="mock-url"][download="file.pdf"]', ); - console.log(document.body.innerHTML, link); + expect(link).not.toBeNull(); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore 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 new file mode 100644 index 000000000000..3e3dd0dfd3dd --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx @@ -0,0 +1,238 @@ +import { ReactNode } from 'react'; +import { gql } from '@apollo/client'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useActivities } from '@/activities/hooks/useActivities'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; + +const mockActivityTarget = { + __typename: 'ActivityTarget', + updatedAt: '2021-08-03T19:20:06.000Z', + createdAt: '2021-08-03T19:20:06.000Z', + personId: '1', + activityId: '234', + companyId: '1', + id: '123', +}; + +const mockActivity = { + __typename: 'Activity', + updatedAt: '2021-08-03T19:20:06.000Z', + createdAt: '2021-08-03T19:20:06.000Z', + completedAt: '2021-08-03T19:20:06.000Z', + reminderAt: '2021-08-03T19:20:06.000Z', + title: 'title', + authorId: '1', + body: 'body', + dueAt: '2021-08-03T19:20:06.000Z', + type: 'type', + assigneeId: '1', + id: '234', +}; + +const defaultResponseData = { + pageInfo: { + hasNextPage: false, + startCursor: '', + endCursor: '', + }, + totalCount: 1, +}; + +const mocks: MockedResponse[] = [ + { + request: { + query: gql` + query FindManyActivityTargets( + $filter: ActivityTargetFilterInput + $orderBy: ActivityTargetOrderByInput + $lastCursor: String + $limit: Float + ) { + activityTargets( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + ) { + edges { + node { + __typename + updatedAt + createdAt + activityId + id + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } + `, + variables: { + filter: { activityTargetId: { eq: '123' } }, + limit: undefined, + orderBy: undefined, + }, + }, + result: jest.fn(() => ({ + data: { + activityTargets: { + ...defaultResponseData, + edges: [ + { + node: mockActivityTarget, + cursor: '1', + }, + ], + }, + }, + })), + }, + { + request: { + query: gql` + query FindManyActivities( + $filter: ActivityFilterInput + $orderBy: ActivityOrderByInput + $lastCursor: String + $limit: Float + ) { + activities( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + ) { + edges { + node { + __typename + createdAt + reminderAt + authorId + title + completedAt + updatedAt + body + dueAt + type + id + assigneeId + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } + `, + variables: { + filter: { id: { in: ['234'] } }, + limit: undefined, + orderBy: {}, + }, + }, + result: jest.fn(() => ({ + data: { + activities: { + ...defaultResponseData, + edges: [ + { + node: mockActivity, + cursor: '1', + }, + ], + }, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useActivities', () => { + it('returns default response', () => { + const { result } = renderHook( + () => + useActivities({ + targetableObjects: [], + activitiesFilters: {}, + activitiesOrderByVariables: {}, + skip: false, + skipActivityTargets: false, + }), + { wrapper: Wrapper }, + ); + + expect(result.current).toEqual({ + activities: [], + loading: false, + initialized: true, + noActivities: true, + }); + }); + + it('fetches activities', async () => { + const { result } = renderHook( + () => { + const setCurrentWorkspaceMember = useSetRecoilState( + currentWorkspaceMemberState, + ); + + const activities = useActivities({ + targetableObjects: [ + { targetObjectNameSingular: 'activityTarget', id: '123' }, + ], + activitiesFilters: {}, + activitiesOrderByVariables: {}, + skip: false, + skipActivityTargets: false, + }); + return { activities, setCurrentWorkspaceMember }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]); + }); + + expect(result.current.activities.loading).toBe(true); + + // Wait for activityTargets to complete fetching + await waitFor(() => !result.current.activities.loading); + + expect(result.current.activities.loading).toBe(false); + + // Wait for request to fetch activities to be made + await waitFor(() => result.current.activities.loading); + + // Wait for activities to complete fetching + await waitFor(() => !result.current.activities.loading); + + const { activities } = result.current; + + expect(activities.activities).toEqual([mockActivity]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityConnectionUtils.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityConnectionUtils.test.tsx deleted file mode 100644 index a43b53fbaef6..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityConnectionUtils.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; -import { Comment } from '@/activities/types/Comment'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; - -const mockActivityWithConnectionRelation = { - activityTargets: { - edges: [ - { - __typename: 'ActivityTargetEdge', - node: { - id: '20202020-1029-4661-9e91-83bad932bdff', - }, - }, - ], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - }, - comments: { - edges: [ - { - __typename: 'CommentEdge', - node: { - id: '20202020-1029-4661-9e91-83bad932bdee', - }, - }, - ] as ObjectRecordEdge[], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - }, -}; - -const mockActivityWithArrayRelation = { - activityTargets: [ - { - id: '20202020-1029-4661-9e91-83bad932bdff', - }, - ], - comments: [ - { - id: '20202020-1029-4661-9e91-83bad932bdee', - }, - ], -}; - -describe('useActivityConnectionUtils', () => { - it('Should turn activity with connection relation in activity with array relation', async () => { - const { result } = renderHook(() => useActivityConnectionUtils(), { - wrapper: ({ children }) => ( - { - snapshot.set( - objectMetadataItemsState, - getObjectMetadataItemsMock(), - ); - }} - > - {children} - - ), - }); - - const { makeActivityWithoutConnection } = result.current; - - const { activity: activityWithArrayRelation } = - makeActivityWithoutConnection(mockActivityWithConnectionRelation as any); - - expect(activityWithArrayRelation).toBeDefined(); - - expect(activityWithArrayRelation.activityTargets[0].id).toEqual( - mockActivityWithArrayRelation.activityTargets[0].id, - ); - }); - - it('Should turn activity with connection relation in activity with array relation', async () => { - const { result } = renderHook(() => useActivityConnectionUtils(), { - wrapper: ({ children }) => ( - { - snapshot.set( - objectMetadataItemsState, - getObjectMetadataItemsMock(), - ); - }} - > - {children} - - ), - }); - - const { makeActivityWithConnection } = result.current; - - const { activityWithConnection } = makeActivityWithConnection( - mockActivityWithArrayRelation as any, - ); - - expect(activityWithConnection).toBeDefined(); - - console.log( - JSON.stringify({ - mockActivityWithConnectionRelation, - activityWithConnection, - mockActivityWithArrayRelation, - }), - ); - - expect(activityWithConnection.activityTargets.edges[0].node.id).toEqual( - mockActivityWithConnectionRelation.activityTargets.edges[0].node.id, - ); - }); -}); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx new file mode 100644 index 000000000000..76a0079eb80c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetObjectRecords.test.tsx @@ -0,0 +1,166 @@ +import { ReactNode } from 'react'; +import { gql, InMemoryCache } from '@apollo/client'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +const cache = new InMemoryCache(); + +const activityNode = { + id: '3ecaa1be-aac7-463a-a38e-64078dd451d5', + createdAt: '2023-04-26T10:12:42.33625+00:00', + updatedAt: '2023-04-26T10:23:42.33625+00:00', + reminderAt: null, + title: 'My very first note', + type: 'Note', + body: '', + dueAt: '2023-04-26T10:12:42.33625+00:00', + completedAt: null, + author: null, + assignee: null, + assigneeId: null, + authorId: null, + comments: { + edges: [], + }, + activityTargets: { + edges: [ + { + node: { + id: '89bb825c-171e-4bcc-9cf7-43448d6fb300', + createdAt: '2023-04-26T10:12:42.33625+00:00', + updatedAt: '2023-04-26T10:23:42.33625+00:00', + personId: null, + companyId: '89bb825c-171e-4bcc-9cf7-43448d6fb280', + company: { + id: '89bb825c-171e-4bcc-9cf7-43448d6fb280', + name: 'Airbnb', + domainName: 'airbnb.com', + }, + person: null, + activityId: '89bb825c-171e-4bcc-9cf7-43448d6fb230', + activity: { + id: '89bb825c-171e-4bcc-9cf7-43448d6fb230', + createdAt: '2023-04-26T10:12:42.33625+00:00', + updatedAt: '2023-04-26T10:23:42.33625+00:00', + }, + __typename: 'ActivityTarget', + }, + __typename: 'ActivityTargetEdge', + }, + ], + __typename: 'ActivityTargetConnection', + }, + __typename: 'Activity' as const, +}; + +cache.writeFragment({ + fragment: gql` + fragment CreateOneActivityInCache on Activity { + id + createdAt + updatedAt + reminderAt + title + body + dueAt + completedAt + author + assignee + assigneeId + authorId + activityTargets { + edges { + node { + id + createdAt + updatedAt + targetObjectNameSingular + personId + companyId + company { + id + name + domainName + } + person + activityId + activity { + id + createdAt + updatedAt + } + __typename + } + } + } + __typename + } + `, + id: activityNode.id, + data: activityNode, +}); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useActivityTargetObjectRecords', () => { + it('return targetObjects', async () => { + const { result } = renderHook( + () => { + const setCurrentWorkspaceMember = useSetRecoilState( + currentWorkspaceMemberState, + ); + const setObjectMetadataItems = useSetRecoilState( + objectMetadataItemsState, + ); + + const { activityTargetObjectRecords } = useActivityTargetObjectRecords( + getRecordFromRecordNode({ recordNode: activityNode as any }), + ); + + return { + activityTargetObjectRecords, + setCurrentWorkspaceMember, + setObjectMetadataItems, + }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]); + result.current.setObjectMetadataItems(mockObjectMetadataItems); + }); + const activityTargetObjectRecords = + result.current.activityTargetObjectRecords; + + expect(activityTargetObjectRecords).toHaveLength(1); + expect(activityTargetObjectRecords[0].activityTarget).toEqual( + activityNode.activityTargets.edges[0].node, + ); + expect(activityTargetObjectRecords[0].targetObject).toEqual( + activityNode.activityTargets.edges[0].node.company, + ); + expect( + activityTargetObjectRecords[0].targetObjectMetadataItem.nameSingular, + ).toEqual('company'); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetsForTargetableObject.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetsForTargetableObject.test.tsx new file mode 100644 index 000000000000..dedd2e3a0cfd --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivityTargetsForTargetableObject.test.tsx @@ -0,0 +1,129 @@ +import { ReactNode } from 'react'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import gql from 'graphql-tag'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; + +const mockActivityTarget = { + __typename: 'ActivityTarget', + updatedAt: '2021-08-03T19:20:06.000Z', + createdAt: '2021-08-03T19:20:06.000Z', + personId: '1', + activityId: '234', + companyId: '1', + id: '123', +}; + +const defaultResponseData = { + pageInfo: { + hasNextPage: false, + startCursor: '', + endCursor: '', + }, + totalCount: 1, +}; + +const mocks: MockedResponse[] = [ + { + request: { + query: gql` + query FindManyActivityTargets( + $filter: ActivityTargetFilterInput + $orderBy: ActivityTargetOrderByInput + $lastCursor: String + $limit: Float + ) { + activityTargets( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + ) { + edges { + node { + __typename + updatedAt + createdAt + personId + activityId + companyId + id + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } + `, + variables: { + filter: { personId: { eq: '1234' } }, + limit: undefined, + orderBy: undefined, + }, + }, + result: jest.fn(() => ({ + data: { + activityTargets: { + ...defaultResponseData, + edges: [ + { + node: mockActivityTarget, + cursor: '1', + }, + ], + }, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useActivityTargetsForTargetableObject', () => { + it('works as expected', async () => { + const { result } = renderHook( + () => { + const setCurrentWorkspaceMember = useSetRecoilState( + currentWorkspaceMemberState, + ); + + const res = useActivityTargetsForTargetableObject({ + targetableObject: { + id: '1234', + targetObjectNameSingular: 'person', + }, + }); + return { ...res, setCurrentWorkspaceMember }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]); + }); + + expect(result.current.loadingActivityTargets).toBe(true); + + await waitFor(() => !result.current.loadingActivityTargets); + + expect(result.current.activityTargets).toEqual([mockActivityTarget]); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInCache.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInCache.test.tsx new file mode 100644 index 000000000000..a23ee3b4eb85 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInCache.test.tsx @@ -0,0 +1,99 @@ +import { ReactNode } from 'react'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import gql from 'graphql-tag'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { useCreateActivityInCache } from '@/activities/hooks/useCreateActivityInCache'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { mockWorkspaceMembers } from '~/testing/mock-data/workspace-members'; + +const mocks: MockedResponse[] = [ + { + request: { + query: gql` + query FindOneWorkspaceMember($objectRecordId: UUID!) { + workspaceMember(filter: { id: { eq: $objectRecordId } }) { + __typename + colorScheme + name { + firstName + lastName + } + locale + userId + avatarUrl + createdAt + updatedAt + id + } + } + `, + variables: { objectRecordId: '20202020-1553-45c6-a028-5a9064cce07f' }, + }, + result: jest.fn(() => ({ + data: { + workspaceMember: mockWorkspaceMembers[0], + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +describe('useCreateActivityInCache', () => { + it('Should create activity in cache', async () => { + const { result } = renderHook( + () => { + const setCurrentWorkspaceMember = useSetRecoilState( + currentWorkspaceMemberState, + ); + const setObjectMetadataItems = useSetRecoilState( + objectMetadataItemsState, + ); + + const res = useCreateActivityInCache(); + return { + ...res, + setCurrentWorkspaceMember, + setObjectMetadataItems, + }; + }, + { wrapper: Wrapper }, + ); + + act(() => { + result.current.setCurrentWorkspaceMember(mockWorkspaceMembers[0]); + result.current.setObjectMetadataItems(mockObjectMetadataItems); + }); + + act(() => { + const res = result.current.createActivityInCache({ + type: 'Note', + targetableObjects: [ + { + targetObjectNameSingular: 'person', + id: '1234', + }, + ], + }); + + expect(res.createdActivityInCache).toHaveProperty('id'); + expect(res.createdActivityInCache).toHaveProperty('__typename'); + expect(res.createdActivityInCache).toHaveProperty('activityTargets'); + }); + }); +}); 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 new file mode 100644 index 000000000000..5c982afaac82 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx @@ -0,0 +1,90 @@ +import { ReactNode } from 'react'; +import { MockedProvider, MockedResponse } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import gql from 'graphql-tag'; +import pick from 'lodash/pick'; +import { RecoilRoot } from 'recoil'; + +import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; +import { mockedActivities } from '~/testing/mock-data/activities'; + +const mockedDate = '2024-03-15T12:00:00.000Z'; +const toISOStringMock = jest.fn(() => mockedDate); +global.Date.prototype.toISOString = toISOStringMock; + +const mockedActivity = { + ...pick(mockedActivities[0], [ + 'id', + 'title', + 'body', + 'type', + 'completedAt', + 'dueAt', + ]), + updatedAt: mockedDate, +}; + +const mocks: MockedResponse[] = [ + { + request: { + query: gql` + mutation CreateOneActivity($input: ActivityCreateInput!) { + createActivity(data: $input) { + __typename + createdAt + reminderAt + authorId + title + completedAt + updatedAt + body + dueAt + type + id + assigneeId + } + } + `, + variables: { + input: mockedActivity, + }, + }, + result: jest.fn(() => ({ + data: { + createActivity: { + ...mockedActivity, + __typename: 'Activity', + assigneeId: '', + authorId: '1', + reminderAt: null, + createdAt: mockedDate, + }, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +describe('useCreateActivityInDB', () => { + it('Should create activity in DB', async () => { + const { result } = renderHook(() => useCreateActivityInDB(), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current.createActivityInDB(mockedActivity); + }); + + expect(mocks[0].result).toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenActivityRightDrawer.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenActivityRightDrawer.test.tsx new file mode 100644 index 000000000000..19a027551a21 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenActivityRightDrawer.test.tsx @@ -0,0 +1,42 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue } from 'recoil'; + +import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; +import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState'; +import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useOpenActivityRightDrawer', () => { + it('works as expected', () => { + const { result } = renderHook( + () => { + const openActivityRightDrawer = useOpenActivityRightDrawer(); + const viewableActivityId = useRecoilValue(viewableActivityIdState); + const activityIdInDrawer = useRecoilValue(activityIdInDrawerState); + return { + openActivityRightDrawer, + activityIdInDrawer, + viewableActivityId, + }; + }, + { + wrapper: Wrapper, + }, + ); + + expect(result.current.activityIdInDrawer).toBeNull(); + expect(result.current.viewableActivityId).toBeNull(); + act(() => { + result.current.openActivityRightDrawer('123'); + }); + expect(result.current.activityIdInDrawer).toBe('123'); + expect(result.current.viewableActivityId).toBe('123'); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx new file mode 100644 index 000000000000..eb60717a4125 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useOpenCreateActivityDrawer.test.tsx @@ -0,0 +1,63 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'; + +import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; +import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState'; +import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; + +const mockUUID = '37873e04-2f83-4468-9ab7-3f87da6cafad'; + +jest.mock('uuid', () => ({ + v4: () => mockUUID, +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +describe('useOpenCreateActivityDrawer', () => { + it('works as expected', async () => { + const { result } = renderHook( + () => { + const openActivityRightDrawer = useOpenCreateActivityDrawer(); + const viewableActivityId = useRecoilValue(viewableActivityIdState); + const activityIdInDrawer = useRecoilValue(activityIdInDrawerState); + const setObjectMetadataItems = useSetRecoilState( + objectMetadataItemsState, + ); + return { + openActivityRightDrawer, + activityIdInDrawer, + viewableActivityId, + setObjectMetadataItems, + }; + }, + { + wrapper: Wrapper, + }, + ); + + act(() => { + result.current.setObjectMetadataItems(mockObjectMetadataItems); + }); + + expect(result.current.activityIdInDrawer).toBeNull(); + expect(result.current.viewableActivityId).toBeNull(); + await act(async () => { + result.current.openActivityRightDrawer({ + type: 'Note', + targetableObjects: [], + }); + }); + expect(result.current.activityIdInDrawer).toBe(mockUUID); + expect(result.current.viewableActivityId).toBe(mockUUID); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivities.ts b/packages/twenty-front/src/modules/activities/hooks/useActivities.ts index 0806cece8009..1c2cb0943bbf 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivities.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivities.ts @@ -2,13 +2,12 @@ import { useEffect, useState } from 'react'; import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback } from 'recoil'; -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; import { useActivityTargetsForTargetableObjects } from '@/activities/hooks/useActivityTargetsForTargetableObjects'; +import { FIND_MANY_ACTIVITIES_QUERY_KEY } from '@/activities/query-keys/FindManyActivitiesQueryKey'; import { Activity } from '@/activities/types/Activity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { OrderByField } from '@/object-metadata/types/OrderByField'; -import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; @@ -29,7 +28,7 @@ export const useActivities = ({ }) => { const [initialized, setInitialized] = useState(false); - const { makeActivityWithoutConnection } = useActivityConnectionUtils(); + const { objectMetadataItems } = useObjectMetadataItems(); const { activityTargets, @@ -40,13 +39,17 @@ export const useActivities = ({ skip: skipActivityTargets || skip, }); - const activityIds = activityTargets - ? [ - ...activityTargets - .map((activityTarget) => activityTarget.activityId) - .filter(isNonEmptyString), - ].sort(sortByAscString) - : []; + const activityIds = [ + ...new Set( + activityTargets + ? [ + ...activityTargets + .map((activityTarget) => activityTarget.activityId) + .filter(isNonEmptyString), + ].sort(sortByAscString) + : [], + ), + ]; const activityTargetsFound = initializedActivityTargets && isNonEmptyArray(activityTargets); @@ -65,23 +68,22 @@ export const useActivities = ({ (!skipActivityTargets && (!initializedActivityTargets || !activityTargetsFound)); - const { records: activitiesWithConnection, loading: loadingActivities } = + const { records: activities, loading: loadingActivities } = useFindManyRecords({ skip: skipActivities, - objectNameSingular: CoreObjectNameSingular.Activity, + objectNameSingular: FIND_MANY_ACTIVITIES_QUERY_KEY.objectNameSingular, + depth: FIND_MANY_ACTIVITIES_QUERY_KEY.depth, + queryFields: + FIND_MANY_ACTIVITIES_QUERY_KEY.fieldsFactory?.(objectMetadataItems), filter, orderBy: activitiesOrderByVariables, onCompleted: useRecoilCallback( ({ set }) => - (data) => { + (activities) => { if (!initialized) { setInitialized(true); } - const activities = getRecordsFromRecordConnection({ - recordConnection: data, - }); - for (const activity of activities) { set(recordStoreFamilyState(activity.id), activity); } @@ -92,11 +94,6 @@ export const useActivities = ({ const loading = loadingActivities || loadingActivityTargets; - // TODO: fix connection in relation => automatically change to an array - const activities: Activity[] = activitiesWithConnection - ?.map(makeActivityWithoutConnection as any) - .map(({ activity }: any) => activity); - const noActivities = (!activityTargetsFound && !skipActivityTargets && initialized) || (initialized && !loading && !isNonEmptyArray(activities)); diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts deleted file mode 100644 index 4f63b2d1b207..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityById.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; - -const QUERY_DEPTH_TO_GET_ACTIVITY_TARGET_RELATIONS = 3; - -export const useActivityById = ({ activityId }: { activityId: string }) => { - const { makeActivityWithoutConnection } = useActivityConnectionUtils(); - - // TODO: fix connection in relation => automatically change to an array - const { record: activityWithConnections, loading } = useFindOneRecord({ - objectNameSingular: CoreObjectNameSingular.Activity, - objectRecordId: activityId, - skip: !activityId, - depth: QUERY_DEPTH_TO_GET_ACTIVITY_TARGET_RELATIONS, - }); - - const { activity } = activityWithConnections - ? makeActivityWithoutConnection(activityWithConnections as any) - : { activity: null }; - - return { - activity, - loading, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityConnectionUtils.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityConnectionUtils.ts deleted file mode 100644 index a858368df3de..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityConnectionUtils.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { isNonEmptyArray } from '@apollo/client/utilities'; - -import { Activity } from '@/activities/types/Activity'; -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { Comment } from '@/activities/types/Comment'; -import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo'; -import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; -import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { isNonNullable } from '~/utils/isNonNullable'; - -export const useActivityConnectionUtils = () => { - const mapConnectionToRecords = useMapConnectionToRecords(); - - const makeActivityWithoutConnection = ( - activityWithConnections: Activity & { - activityTargets: ObjectRecordConnection; - comments: ObjectRecordConnection; - }, - ) => { - if (!isNonNullable(activityWithConnections)) { - throw new Error('Activity with connections is not defined'); - } - - const hasActivityTargetsConnection = isObjectRecordConnection( - CoreObjectNameSingular.ActivityTarget, - activityWithConnections?.activityTargets, - ); - - const activityTargets: ActivityTarget[] = []; - - if (hasActivityTargetsConnection) { - const newActivityTargets = mapConnectionToRecords({ - objectRecordConnection: activityWithConnections?.activityTargets, - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - depth: 5, - }) as ActivityTarget[]; - - activityTargets.push(...newActivityTargets); - } - - const hasCommentsConnection = isObjectRecordConnection( - CoreObjectNameSingular.Comment, - activityWithConnections?.comments, - ); - - const comments: Comment[] = []; - - if (hasCommentsConnection) { - const newComments = mapConnectionToRecords({ - objectRecordConnection: activityWithConnections?.comments, - objectNameSingular: CoreObjectNameSingular.Comment, - depth: 5, - }) as Comment[]; - - comments.push(...newComments); - } - - const activity: Activity = { - ...activityWithConnections, - activityTargets, - comments, - }; - - return { activity }; - }; - - const makeActivityWithConnection = (activity: Activity) => { - const activityTargetEdges = isNonEmptyArray(activity?.activityTargets) - ? activity.activityTargets.map((activityTarget) => ({ - node: activityTarget, - cursor: '', - })) - : []; - - const commentEdges = isNonEmptyArray(activity?.comments) - ? activity.comments.map((comment) => ({ - node: comment, - cursor: '', - })) - : []; - - const activityTargets = { - __typename: 'ActivityTargetConnection', - edges: activityTargetEdges, - pageInfo: getEmptyPageInfo(), - } as ObjectRecordConnection; - - const comments = { - __typename: 'CommentConnection', - edges: commentEdges, - pageInfo: getEmptyPageInfo(), - } as ObjectRecordConnection; - - const activityWithConnection = { - ...activity, - activityTargets, - comments, - } as Activity & { - activityTargets: ObjectRecordConnection; - comments: ObjectRecordConnection; - }; - - return { activityWithConnection }; - }; - - return { - makeActivityWithoutConnection, - makeActivityWithConnection, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts index 15e1fb1f9d57..7f403d89dd4d 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetObjectRecords.ts @@ -1,56 +1,67 @@ -import { isNonEmptyString } from '@sniptt/guards'; +import { useApolloClient } from '@apollo/client'; import { useRecoilValue } from 'recoil'; +import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { Nullable } from '~/types/Nullable'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; -export const useActivityTargetObjectRecords = ({ - activityId, -}: { - activityId: string; -}) => { +export const useActivityTargetObjectRecords = (activity: Activity) => { const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const { records: activityTargets, loading: loadingActivityTargets } = - useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - skip: !isNonEmptyString(activityId), - filter: { - activityId: { - eq: activityId, - }, - }, - }); + const activityTargets = activity.activityTargets ?? []; + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + }); + + const apolloClient = useApolloClient(); const activityTargetObjectRecords = activityTargets .map>((activityTarget) => { + const activityTargetFromCache = getRecordFromCache( + activityTarget.id, + apolloClient.cache, + ); + + if (!isDefined(activityTargetFromCache)) { + throw new Error( + `Cannot find activity target ${activityTarget.id} in cache, this shouldn't happen.`, + ); + } + const correspondingObjectMetadataItem = objectMetadataItems.find( (objectMetadataItem) => - isNonNullable(activityTarget[objectMetadataItem.nameSingular]) && + isDefined(activityTargetFromCache[objectMetadataItem.nameSingular]) && !objectMetadataItem.isSystem, ); if (!correspondingObjectMetadataItem) { - return null; + return undefined; + } + + const targetObjectRecord = + activityTargetFromCache[correspondingObjectMetadataItem.nameSingular]; + + if (!targetObjectRecord) { + throw new Error( + `Cannot find target object record of type ${correspondingObjectMetadataItem.nameSingular}, make sure the request for activities eagerly loads for the target objects on activity target relation.`, + ); } return { - activityTarget: activityTarget, - targetObject: - activityTarget[correspondingObjectMetadataItem.nameSingular], + activityTarget: activityTargetFromCache ?? activityTarget, + targetObject: targetObjectRecord ?? undefined, targetObjectMetadataItem: correspondingObjectMetadataItem, - targetObjectNameSingular: correspondingObjectMetadataItem.nameSingular, }; }) - .filter(isNonNullable); + .filter(isDefined); return { activityTargetObjectRecords, - loadingActivityTargets, }; }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts index a48dec8f8dd2..7d2a8e762cfe 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObject.ts @@ -3,7 +3,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -26,7 +26,7 @@ export const useActivityTargetsForTargetableObject = ({ // If we are on a show page and we remove the current show page object corresponding activity target // See also if we need to update useTimelineActivities const { records: activityTargets, loading: loadingActivityTargets } = - useFindManyRecords({ + useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, skip: skipRequest, filter: { @@ -42,7 +42,7 @@ export const useActivityTargetsForTargetableObject = ({ }); return { - activityTargets: activityTargets as ActivityTarget[], + activityTargets, loadingActivityTargets, initialized, }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts index 2dbe174a6ac2..be828110297a 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useActivityTargetsForTargetableObjects.ts @@ -1,9 +1,11 @@ import { useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import { FIND_MANY_ACTIVITY_TARGETS_QUERY_KEY } from '@/activities/query-keys/FindManyActivityTargetsQueryKey'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; export const useActivityTargetsForTargetableObjects = ({ @@ -20,16 +22,23 @@ export const useActivityTargetsForTargetableObjects = ({ targetableObjects: targetableObjects, }); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const [initialized, setInitialized] = useState(false); // TODO: We want to optimistically remove from this request // If we are on a show page and we remove the current show page object corresponding activity target // See also if we need to update useTimelineActivities const { records: activityTargets, loading: loadingActivityTargets } = - useFindManyRecords({ + useFindManyRecords({ skip, - objectNameSingular: CoreObjectNameSingular.ActivityTarget, + objectNameSingular: + FIND_MANY_ACTIVITY_TARGETS_QUERY_KEY.objectNameSingular, filter: activityTargetsFilter, + queryFields: + FIND_MANY_ACTIVITY_TARGETS_QUERY_KEY.fieldsFactory?.( + objectMetadataItems, + ), onCompleted: () => { if (!initialized) { setInitialized(true); @@ -38,7 +47,7 @@ export const useActivityTargetsForTargetableObjects = ({ }); return { - activityTargets: activityTargets as ActivityTarget[], + activityTargets, loadingActivityTargets, initialized, }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useAttachRelationInBothDirections.ts b/packages/twenty-front/src/modules/activities/hooks/useAttachRelationInBothDirections.ts deleted file mode 100644 index bdc0df87d088..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useAttachRelationInBothDirections.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { useApolloClient } from '@apollo/client'; -import { StringKeyOf } from 'type-fest'; - -import { getRelationDefinition } from '@/apollo/optimistic-effect/utils/getRelationDefinition'; -import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { getObjectMetadataItemByNameSingular } from '@/object-metadata/utils/getObjectMetadataItemBySingularName'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { isNonNullable } from '~/utils/isNonNullable'; - -export const useAttachRelationInBothDirections = () => { - const { objectMetadataItems } = useObjectMetadataItems(); - - const apolloClient = useApolloClient(); - - const attachRelationInBothDirections = < - Source extends ObjectRecord = ObjectRecord, - Target extends ObjectRecord = ObjectRecord, - >({ - sourceRecord, - targetRecords, - sourceObjectNameSingular, - targetObjectNameSingular, - fieldNameOnSourceRecord, - fieldNameOnTargetRecord, - }: { - sourceRecord: Source; - targetRecords: Target[]; - sourceObjectNameSingular: string; - targetObjectNameSingular: string; - fieldNameOnSourceRecord: StringKeyOf; - fieldNameOnTargetRecord: StringKeyOf; - }) => { - const sourceObjectMetadataItem = getObjectMetadataItemByNameSingular({ - objectMetadataItems, - objectNameSingular: sourceObjectNameSingular, - }); - - const targetObjectMetadataItem = getObjectMetadataItemByNameSingular({ - objectMetadataItems, - objectNameSingular: targetObjectNameSingular, - }); - - const fieldMetadataItemOnSourceRecord = - sourceObjectMetadataItem.fields.find( - (field) => field.name === fieldNameOnSourceRecord, - ); - - if (!isNonNullable(fieldMetadataItemOnSourceRecord)) { - throw new Error( - `Field ${fieldNameOnSourceRecord} not found on object ${sourceObjectNameSingular}`, - ); - } - - const relationDefinition = getRelationDefinition({ - fieldMetadataItemOnSourceRecord: fieldMetadataItemOnSourceRecord, - objectMetadataItems, - }); - - if (!isNonNullable(relationDefinition)) { - throw new Error( - `Relation metadata not found for field ${fieldNameOnSourceRecord} on object ${sourceObjectNameSingular}`, - ); - } - - // TODO: could we use triggerUpdateRelationsOptimisticEffect here? - targetRecords.forEach((relationTargetRecord) => { - triggerAttachRelationOptimisticEffect({ - cache: apolloClient.cache, - sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, - sourceRecordId: sourceRecord.id, - fieldNameOnTargetRecord: fieldNameOnTargetRecord, - targetObjectNameSingular: targetObjectMetadataItem.nameSingular, - targetRecordId: relationTargetRecord.id, - }); - - triggerAttachRelationOptimisticEffect({ - cache: apolloClient.cache, - sourceObjectNameSingular: targetObjectMetadataItem.nameSingular, - sourceRecordId: relationTargetRecord.id, - fieldNameOnTargetRecord: fieldNameOnSourceRecord, - targetObjectNameSingular: sourceObjectMetadataItem.nameSingular, - targetRecordId: sourceRecord.id, - }); - }); - }; - - return { - attachRelationInBothDirections, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts b/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts index 5c1bd50d79b1..8f54fdf1f7d5 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInCache.ts @@ -1,20 +1,24 @@ +import { Reference, useApolloClient } from '@apollo/client'; import { useRecoilCallback, useRecoilValue } from 'recoil'; import { v4 } from 'uuid'; -import { useAttachRelationInBothDirections } from '@/activities/hooks/useAttachRelationInBothDirections'; -import { useInjectIntoActivityTargetInlineCellCache } from '@/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache'; import { Activity, ActivityType } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { makeActivityTargetsToCreateFromTargetableObjects } from '@/activities/utils/getActivityTargetsToCreateFromTargetableObjects'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateManyRecordsInCache } from '@/object-record/hooks/useCreateManyRecordsInCache'; -import { useCreateOneRecordInCache } from '@/object-record/hooks/useCreateOneRecordInCache'; +import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache'; +import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache'; +import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; +import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const useCreateActivityInCache = () => { const { createManyRecordsInCache: createManyActivityTargetsInCache } = @@ -22,40 +26,48 @@ export const useCreateActivityInCache = () => { objectNameSingular: CoreObjectNameSingular.ActivityTarget, }); - const { createOneRecordInCache: createOneActivityInCache } = - useCreateOneRecordInCache({ - objectNameSingular: CoreObjectNameSingular.Activity, - }); + const cache = useApolloClient().cache; + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { record: currentWorkspaceMemberRecord } = useFindOneRecord({ objectNameSingular: CoreObjectNameSingular.WorkspaceMember, objectRecordId: currentWorkspaceMember?.id, - depth: 3, + depth: 0, }); - const { injectIntoActivityTargetInlineCellCache } = - useInjectIntoActivityTargetInlineCellCache(); + const { objectMetadataItem: objectMetadataItemActivity } = + useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); + + const { objectMetadataItem: objectMetadataItemActivityTarget } = + useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + }); - const { attachRelationInBothDirections } = - useAttachRelationInBothDirections(); + const createOneActivityInCache = useCreateOneRecordInCache({ + objectMetadataItem: objectMetadataItemActivity, + }); const createActivityInCache = useRecoilCallback( ({ snapshot, set }) => ({ type, - targetableObjects, + targetObject, customAssignee, }: { type: ActivityType; - targetableObjects: ActivityTargetableObject[]; + targetObject?: ActivityTargetableObject; customAssignee?: WorkspaceMember; }) => { const activityId = v4(); const createdActivityInCache = createOneActivityInCache({ id: activityId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), author: currentWorkspaceMemberRecord, authorId: currentWorkspaceMemberRecord?.id, assignee: customAssignee ?? currentWorkspaceMemberRecord, @@ -63,42 +75,89 @@ export const useCreateActivityInCache = () => { type, }); - const targetObjectRecords = targetableObjects - .map((targetableObject) => { - const targetObject = snapshot - .getLoadable(recordStoreFamilyState(targetableObject.id)) - .getValue(); + if (isUndefinedOrNull(createdActivityInCache)) { + throw new Error('Failed to create activity in cache'); + } + + if (isUndefinedOrNull(targetObject)) { + set(recordStoreFamilyState(activityId), { + ...createdActivityInCache, + activityTargets: [], + comments: [], + }); + + return { + createdActivityInCache: { + ...createdActivityInCache, + activityTargets: [], + }, + }; + } + + const targetObjectRecord = snapshot + .getLoadable(recordStoreFamilyState(targetObject.id)) + .getValue(); - return targetObject; - }) - .filter(isNonNullable); + if (isUndefinedOrNull(targetObjectRecord)) { + throw new Error('Failed to find target object record'); + } const activityTargetsToCreate = makeActivityTargetsToCreateFromTargetableObjects({ - activityId, - targetableObjects, - targetObjectRecords, + activity: createdActivityInCache, + targetableObjects: [targetObject], + targetObjectRecords: [targetObjectRecord], }); const createdActivityTargetsInCache = createManyActivityTargetsInCache( activityTargetsToCreate, ); - injectIntoActivityTargetInlineCellCache({ - activityId, - activityTargetsToInject: createdActivityTargetsInCache, + const activityTargetsConnection = getRecordConnectionFromRecords({ + objectMetadataItems: objectMetadataItems, + objectMetadataItem: objectMetadataItemActivityTarget, + records: createdActivityTargetsInCache, + withPageInfo: false, + computeReferences: true, + isRootLevel: false, }); - attachRelationInBothDirections({ - sourceRecord: createdActivityInCache, - fieldNameOnSourceRecord: 'activityTargets', - sourceObjectNameSingular: CoreObjectNameSingular.Activity, - fieldNameOnTargetRecord: 'activity', - targetObjectNameSingular: CoreObjectNameSingular.ActivityTarget, - targetRecords: createdActivityTargetsInCache, + modifyRecordFromCache({ + recordId: createdActivityInCache.id, + cache, + fieldModifiers: { + activityTargets: () => activityTargetsConnection, + }, + objectMetadataItem: objectMetadataItemActivity, }); - // TODO: should refactor when refactoring make activity connection utils + const targetObjectMetadataItem = objectMetadataItems.find( + (item) => item.nameSingular === targetObject.targetObjectNameSingular, + ); + + if (isDefined(targetObjectMetadataItem)) { + modifyRecordFromCache({ + cache, + objectMetadataItem: targetObjectMetadataItem, + recordId: targetObject.id, + fieldModifiers: { + activityTargets: (activityTargetsRef, { readField }) => { + const edges = readField<{ node: Reference }[]>( + 'edges', + activityTargetsRef, + ); + + if (!edges) return activityTargetsRef; + + return { + ...activityTargetsRef, + edges: [...edges, ...activityTargetsConnection.edges], + }; + }, + }, + }); + } + set(recordStoreFamilyState(activityId), { ...createdActivityInCache, activityTargets: createdActivityTargetsInCache, @@ -110,15 +169,16 @@ export const useCreateActivityInCache = () => { ...createdActivityInCache, activityTargets: createdActivityTargetsInCache, }, - createdActivityTargetsInCache, }; }, [ - attachRelationInBothDirections, - createManyActivityTargetsInCache, createOneActivityInCache, currentWorkspaceMemberRecord, - injectIntoActivityTargetInlineCellCache, + createManyActivityTargetsInCache, + objectMetadataItems, + objectMetadataItemActivityTarget, + cache, + objectMetadataItemActivity, ], ); diff --git a/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInDB.ts b/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInDB.ts index 5ade36582011..58e0349e28e0 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInDB.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useCreateActivityInDB.ts @@ -1,6 +1,6 @@ import { isNonEmptyArray } from '@sniptt/guards'; -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; +import { CREATE_ONE_ACTIVITY_QUERY_KEY } from '@/activities/query-keys/CreateOneActivityQueryKey'; import { ActivityForEditor } from '@/activities/types/ActivityForEditor'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; @@ -9,37 +9,27 @@ import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; export const useCreateActivityInDB = () => { const { createOneRecord: createOneActivity } = useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Activity, + objectNameSingular: CREATE_ONE_ACTIVITY_QUERY_KEY.objectNameSingular, + queryFields: CREATE_ONE_ACTIVITY_QUERY_KEY.fields, + depth: CREATE_ONE_ACTIVITY_QUERY_KEY.depth, }); const { createManyRecords: createManyActivityTargets } = useCreateManyRecords({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, + skipPostOptmisticEffect: true, }); - const { makeActivityWithConnection } = useActivityConnectionUtils(); - const createActivityInDB = async (activityToCreate: ActivityForEditor) => { - const { activityWithConnection } = makeActivityWithConnection( - activityToCreate as any, // TODO: fix type - ); - - await createOneActivity?.( - { - ...activityWithConnection, - updatedAt: new Date().toISOString(), - }, - { - skipOptimisticEffect: true, - }, - ); + await createOneActivity?.({ + ...activityToCreate, + updatedAt: new Date().toISOString(), + }); const activityTargetsToCreate = activityToCreate.activityTargets ?? []; if (isNonEmptyArray(activityTargetsToCreate)) { - await createManyActivityTargets(activityTargetsToCreate, { - skipOptimisticEffect: true, - }); + await createManyActivityTargets(activityTargetsToCreate); } }; diff --git a/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts b/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts new file mode 100644 index 000000000000..ed32eb3f0d0f --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useCustomResolver.ts @@ -0,0 +1,121 @@ +import { useState } from 'react'; +import { + DocumentNode, + OperationVariables, + TypedDocumentNode, + useQuery, +} from '@apollo/client'; + +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; + +type CustomResolverQueryResult< + T extends { + [key: string]: any; + }, +> = { + [queryName: string]: T; +}; + +export const useCustomResolver = < + T extends { + [key: string]: any; + }, +>( + query: + | DocumentNode + | TypedDocumentNode, OperationVariables>, + queryName: string, + objectName: string, + activityTargetableObject: ActivityTargetableObject, + pageSize: number, +): { + data: CustomResolverQueryResult | undefined; + firstQueryLoading: boolean; + isFetchingMore: boolean; + fetchMoreRecords: () => Promise; +} => { + const { enqueueSnackBar } = useSnackBar(); + + const [page, setPage] = useState({ + pageNumber: 1, + hasNextPage: true, + }); + + const [isFetchingMore, setIsFetchingMore] = useState(false); + + const queryVariables = { + ...(activityTargetableObject.targetObjectNameSingular === + CoreObjectNameSingular.Person + ? { personId: activityTargetableObject.id } + : { companyId: activityTargetableObject.id }), + page: 1, + pageSize, + }; + + const { + data, + loading: firstQueryLoading, + fetchMore, + } = useQuery>(query, { + variables: queryVariables, + onError: (error) => { + enqueueSnackBar(error.message || `Error loading ${objectName}`, { + variant: 'error', + }); + }, + }); + + const fetchMoreRecords = async () => { + if (page.hasNextPage && !isFetchingMore && !firstQueryLoading) { + setIsFetchingMore(true); + + await fetchMore({ + variables: { + ...queryVariables, + page: page.pageNumber + 1, + }, + updateQuery: (prev, { fetchMoreResult }) => { + if (!fetchMoreResult?.[queryName]?.[objectName]?.length) { + setPage((page) => ({ + ...page, + hasNextPage: false, + })); + + return { + [queryName]: { + ...prev?.[queryName], + [objectName]: [...(prev?.[queryName]?.[objectName] ?? [])], + }, + }; + } + + return { + [queryName]: { + ...prev?.[queryName], + [objectName]: [ + ...(prev?.[queryName]?.[objectName] ?? []), + ...(fetchMoreResult?.[queryName]?.[objectName] ?? []), + ], + }, + }; + }, + }); + + setPage((page) => ({ + ...page, + pageNumber: page.pageNumber + 1, + })); + + setIsFetchingMore(false); + } + }; + + return { + data, + firstQueryLoading, + isFetchingMore, + fetchMoreRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useDeleteActivityFromCache.ts b/packages/twenty-front/src/modules/activities/hooks/useDeleteActivityFromCache.ts deleted file mode 100644 index f4cd5914bd64..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useDeleteActivityFromCache.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useApolloClient } from '@apollo/client'; - -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; -import { ActivityForEditor } from '@/activities/types/ActivityForEditor'; -import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; - -// TODO: this should be useDeleteRecordFromCache -export const useDeleteActivityFromCache = () => { - const { makeActivityWithConnection } = useActivityConnectionUtils(); - - const apolloClient = useApolloClient(); - - const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.Activity, - }); - - const { objectMetadataItems } = useObjectMetadataItems(); - - const deleteActivityFromCache = (activityToDelete: ActivityForEditor) => { - const { activityWithConnection } = makeActivityWithConnection( - activityToDelete as any, // TODO: fix type - ); - - triggerDeleteRecordsOptimisticEffect({ - cache: apolloClient.cache, - objectMetadataItem: objectMetadataItemActivity, - objectMetadataItems, - recordsToDelete: [activityWithConnection], - }); - }; - - return { - deleteActivityFromCache, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivitiesQueries.ts deleted file mode 100644 index eb07c0ddcec2..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivitiesQueries.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; - -import { Activity } from '@/activities/types/Activity'; -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { OrderByField } from '@/object-metadata/types/OrderByField'; -import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; -import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { sortByAscString } from '~/utils/array/sortByAscString'; - -// TODO: create a generic hook from this -export const useInjectIntoActivitiesQueries = () => { - const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.Activity, - }); - - const { - upsertFindManyRecordsQueryInCache: overwriteFindManyActivitiesInCache, - } = useUpsertFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivity, - }); - - const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - - const { - readFindManyRecordsQueryInCache: readFindManyActivityTargetsQueryInCache, - } = useReadFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const { - readFindManyRecordsQueryInCache: readFindManyActivitiesQueryInCache, - } = useReadFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivity, - }); - - const injectActivitiesQueries = ({ - activityToInject, - activityTargetsToInject, - targetableObjects, - activitiesFilters, - activitiesOrderByVariables, - injectOnlyInIdFilter, - }: { - activityToInject: Activity; - activityTargetsToInject: ActivityTarget[]; - targetableObjects: ActivityTargetableObject[]; - activitiesFilters?: ObjectRecordQueryFilter; - activitiesOrderByVariables?: OrderByField; - injectOnlyInIdFilter?: boolean; - }) => { - const hasActivityTargets = isNonEmptyArray(targetableObjects); - - if (hasActivityTargets) { - const findManyActivitiyTargetsQueryFilter = getActivityTargetsFilter({ - targetableObjects, - }); - - const findManyActivitiyTargetsQueryVariables = { - filter: findManyActivitiyTargetsQueryFilter, - }; - - const existingActivityTargetsWithMaybeDuplicates = - readFindManyActivityTargetsQueryInCache({ - queryVariables: findManyActivitiyTargetsQueryVariables, - }); - - const existingActivityTargetsWithoutDuplicates: ObjectRecord[] = - existingActivityTargetsWithMaybeDuplicates.filter( - (existingActivityTarget) => - !activityTargetsToInject.some( - (activityTargetToInject) => - activityTargetToInject.id === existingActivityTarget.id, - ), - ); - - const existingActivityIdsFromTargets = - existingActivityTargetsWithoutDuplicates - ?.map((activityTarget) => activityTarget.activityId) - .filter(isNonEmptyString); - - const currentFindManyActivitiesQueryVariables = { - filter: { - id: { - in: [...existingActivityIdsFromTargets].sort(sortByAscString), - }, - ...activitiesFilters, - }, - orderBy: activitiesOrderByVariables, - }; - - const existingActivities = readFindManyActivitiesQueryInCache({ - queryVariables: currentFindManyActivitiesQueryVariables, - }); - - const nextActivityIds = [ - ...existingActivityIdsFromTargets, - activityToInject.id, - ]; - - const nextFindManyActivitiesQueryVariables = { - filter: { - id: { - in: [...nextActivityIds].sort(sortByAscString), - }, - ...activitiesFilters, - }, - orderBy: activitiesOrderByVariables, - }; - - const newActivities = [...existingActivities]; - - if (!injectOnlyInIdFilter) { - const newActivity = { - ...activityToInject, - __typename: 'Activity', - }; - - newActivities.unshift(newActivity); - } - - overwriteFindManyActivitiesInCache({ - objectRecordsToOverwrite: newActivities, - queryVariables: nextFindManyActivitiesQueryVariables, - }); - } else { - const currentFindManyActivitiesQueryVariables = { - filter: { - ...activitiesFilters, - }, - orderBy: activitiesOrderByVariables, - }; - - const existingActivities = readFindManyActivitiesQueryInCache({ - queryVariables: currentFindManyActivitiesQueryVariables, - }); - - const nextFindManyActivitiesQueryVariables = { - filter: { - ...activitiesFilters, - }, - orderBy: activitiesOrderByVariables, - }; - - const newActivities = [...existingActivities]; - - if (!injectOnlyInIdFilter) { - const newActivity = { - ...activityToInject, - __typename: 'Activity', - }; - - newActivities.unshift(newActivity); - } - - overwriteFindManyActivitiesInCache({ - objectRecordsToOverwrite: newActivities, - queryVariables: nextFindManyActivitiesQueryVariables, - }); - } - }; - - return { - injectActivitiesQueries, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivityTargetsQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivityTargetsQueries.ts deleted file mode 100644 index 1b70bc053303..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useInjectIntoActivityTargetsQueries.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { isNonEmptyArray } from '@sniptt/guards'; - -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; -import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; - -// TODO: create a generic hook from this -export const useInjectIntoActivityTargetsQueries = () => { - const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - - const { - readFindManyRecordsQueryInCache: readFindManyActivityTargetsQueryInCache, - } = useReadFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const { - upsertFindManyRecordsQueryInCache: - overwriteFindManyActivityTargetsQueryInCache, - } = useUpsertFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const injectActivityTargetsQueries = ({ - activityTargetsToInject, - targetableObjects, - }: { - activityTargetsToInject: ActivityTarget[]; - targetableObjects: ActivityTargetableObject[]; - }) => { - const hasActivityTargets = isNonEmptyArray(targetableObjects); - - if (!hasActivityTargets) { - return; - } - - const findManyActivitiyTargetsQueryFilter = getActivityTargetsFilter({ - targetableObjects, - }); - - const findManyActivitiyTargetsQueryVariables = { - filter: findManyActivitiyTargetsQueryFilter, - }; - - const existingActivityTargetsWithMaybeDuplicates = - readFindManyActivityTargetsQueryInCache({ - queryVariables: findManyActivitiyTargetsQueryVariables, - }); - - const existingActivityTargetsWithoutDuplicates: ObjectRecord[] = - existingActivityTargetsWithMaybeDuplicates.filter( - (existingActivityTarget) => - !activityTargetsToInject.some( - (activityTargetToInject) => - activityTargetToInject.id === existingActivityTarget.id, - ), - ); - - const newActivityTargets = [ - ...existingActivityTargetsWithoutDuplicates, - ...activityTargetsToInject, - ]; - - overwriteFindManyActivityTargetsQueryInCache({ - objectRecordsToOverwrite: newActivityTargets, - queryVariables: findManyActivitiyTargetsQueryVariables, - }); - }; - - return { - injectActivityTargetsQueries, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useModifyActivityOnActivityTargetCache.ts b/packages/twenty-front/src/modules/activities/hooks/useModifyActivityOnActivityTargetCache.ts deleted file mode 100644 index 84683c61571d..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useModifyActivityOnActivityTargetCache.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useApolloClient } from '@apollo/client'; - -import { Activity } from '@/activities/types/Activity'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; -import { getCacheReferenceFromRecord } from '@/object-record/cache/utils/getCacheReferenceFromRecord'; - -export const useModifyActivityOnActivityTargetsCache = () => { - const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - - const modifyActivityTargetFromCache = useModifyRecordFromCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const apolloClient = useApolloClient(); - - const modifyActivityOnActivityTargetsCache = ({ - activityTargetIds, - activity, - }: { - activityTargetIds: string[]; - activity: Activity; - }) => { - for (const activityTargetId of activityTargetIds) { - modifyActivityTargetFromCache(activityTargetId, { - activity: () => { - const newActivityReference = getCacheReferenceFromRecord({ - apolloClient, - objectNameSingular: CoreObjectNameSingular.Activity, - record: activity, - }); - - return newActivityReference; - }, - }); - } - }; - - return { - modifyActivityOnActivityTargetsCache, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useModifyActivityTargetsOnActivityCache.ts b/packages/twenty-front/src/modules/activities/hooks/useModifyActivityTargetsOnActivityCache.ts deleted file mode 100644 index 357fae472c5d..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useModifyActivityTargetsOnActivityCache.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { useApolloClient } from '@apollo/client'; - -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; -import { getCachedRecordEdgesFromRecords } from '@/object-record/cache/utils/getCachedRecordEdgesFromRecords'; - -export const useModifyActivityTargetsOnActivityCache = () => { - const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Activity, - }); - - const modifyActivityFromCache = useModifyRecordFromCache({ - objectMetadataItem: objectMetadataItemActivity, - }); - - const apolloClient = useApolloClient(); - - const modifyActivityTargetsOnActivityCache = ({ - activityId, - activityTargets, - }: { - activityId: string; - activityTargets: ActivityTarget[]; - }) => { - modifyActivityFromCache(activityId, { - activityTargets: ( - activityTargetsCachedConnection: CachedObjectRecordConnection, - ) => { - const newActivityTargetsCachedRecordEdges = - getCachedRecordEdgesFromRecords({ - apolloClient, - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - records: activityTargets, - }); - - return { - ...activityTargetsCachedConnection, - edges: newActivityTargetsCachedRecordEdges, - }; - }, - }); - }; - - return { - modifyActivityTargetsOnActivityCache, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts index 9bcc1ec21ded..b0279e17eece 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenActivityRightDrawer.ts @@ -1,4 +1,4 @@ -import { useRecoilState } from 'recoil'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; @@ -14,7 +14,7 @@ export const useOpenActivityRightDrawer = () => { const [viewableActivityId, setViewableActivityId] = useRecoilState( viewableActivityIdState, ); - const [, setActivityIdInDrawer] = useRecoilState(activityIdInDrawerState); + const setActivityIdInDrawer = useSetRecoilState(activityIdInDrawerState); const setHotkeyScope = useSetHotkeyScope(); return (activityId: string) => { diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts index 6b7e83c447f1..521473627095 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawer.ts @@ -23,10 +23,10 @@ export const useOpenCreateActivityDrawer = () => { const { createActivityInCache } = useCreateActivityInCache(); - const [, setActivityTargetableEntityArray] = useRecoilState( + const setActivityTargetableEntityArray = useSetRecoilState( activityTargetableEntityArrayState, ); - const [, setViewableActivityId] = useRecoilState(viewableActivityIdState); + const setViewableActivityId = useSetRecoilState(viewableActivityIdState); const setIsCreatingActivity = useSetRecoilState(isActivityInCreateModeState); @@ -51,7 +51,7 @@ export const useOpenCreateActivityDrawer = () => { }) => { const { createdActivityInCache } = createActivityInCache({ type, - targetableObjects, + targetObject: targetableObjects[0], customAssignee, }); diff --git a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts b/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts deleted file mode 100644 index 9cde4d7439c5..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useOpenCreateActivityDrawerForSelectedRowIds.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; -import { ActivityType } from '@/activities/types/Activity'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; -import { isNonNullable } from '~/utils/isNonNullable'; - -import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; - -export const useOpenCreateActivityDrawerForSelectedRowIds = ( - recordTableId: string, -) => { - const openCreateActivityDrawer = useOpenCreateActivityDrawer(); - - const { getSelectedRowIdsSelector } = useRecordTableStates(recordTableId); - - return useRecoilCallback( - ({ snapshot }) => - ( - type: ActivityType, - objectNameSingular: string, - relatedEntities?: ActivityTargetableObject[], - ) => { - const selectedRowIds = getSnapshotValue( - snapshot, - getSelectedRowIdsSelector(), - ); - - let activityTargetableObjectArray: ActivityTargetableObject[] = - selectedRowIds - .map((recordId: string) => { - const targetObjectRecord = getSnapshotValue( - snapshot, - recordStoreFamilyState(recordId), - ); - - if (!targetObjectRecord) { - return null; - } - - return { - type: 'Custom', - targetObjectNameSingular: objectNameSingular, - id: recordId, - targetObjectRecord, - }; - }) - .filter(isNonNullable); - - if (relatedEntities) { - activityTargetableObjectArray = - activityTargetableObjectArray.concat(relatedEntities); - } - - openCreateActivityDrawer({ - type, - targetableObjects: activityTargetableObjectArray, - }); - }, - [openCreateActivityDrawer, getSelectedRowIdsSelector], - ); -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/usePrepareFindManyActivitiesQuery.ts b/packages/twenty-front/src/modules/activities/hooks/usePrepareFindManyActivitiesQuery.ts new file mode 100644 index 000000000000..9d2e50b837da --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/usePrepareFindManyActivitiesQuery.ts @@ -0,0 +1,123 @@ +import { useApolloClient } from '@apollo/client'; + +import { FIND_MANY_ACTIVITIES_QUERY_KEY } from '@/activities/query-keys/FindManyActivitiesQueryKey'; +import { Activity } from '@/activities/types/Activity'; +import { ActivityTarget } from '@/activities/types/ActivityTarget'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +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 { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; +import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { sortByAscString } from '~/utils/array/sortByAscString'; +import { isDefined } from '~/utils/isDefined'; + +export const usePrepareFindManyActivitiesQuery = () => { + const { objectMetadataItem: objectMetadataItemActivity } = + useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); + + const getActivityFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); + + const cache = useApolloClient().cache; + const { objectMetadataItems } = useObjectMetadataItems(); + + const { upsertFindManyRecordsQueryInCache: upsertFindManyActivitiesInCache } = + useUpsertFindManyRecordsQueryInCache({ + objectMetadataItem: objectMetadataItemActivity, + }); + + const prepareFindManyActivitiesQuery = ({ + targetableObject, + additionalFilter, + shouldActivityBeExcluded, + }: { + additionalFilter?: Record; + targetableObject: ActivityTargetableObject; + shouldActivityBeExcluded?: (activityTarget: Activity) => boolean; + }) => { + const targetableObjectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === + targetableObject.targetObjectNameSingular, + ); + + if (!targetableObjectMetadataItem) { + throw new Error( + `Cannot find object metadata item for targetable object ${targetableObject.targetObjectNameSingular}`, + ); + } + + const targetableObjectRecord = getRecordFromCache({ + recordId: targetableObject.id, + objectMetadataItem: targetableObjectMetadataItem, + objectMetadataItems, + cache, + }); + + const activityTargets: ActivityTarget[] = + targetableObjectRecord?.activityTargets ?? []; + + const activityTargetIds = [ + ...new Set( + activityTargets + .map((activityTarget) => activityTarget.id) + .filter(isDefined), + ), + ]; + + const activities: Activity[] = activityTargetIds + .map((activityTargetId) => { + const activityTarget = activityTargets.find( + (activityTarget) => activityTarget.id === activityTargetId, + ); + + if (!activityTarget) { + return undefined; + } + + return getActivityFromCache(activityTarget.activityId); + }) + .filter(isDefined); + + const activityIds = [...new Set(activities.map((activity) => activity.id))]; + + const nextFindManyActivitiesQueryFilter = { + filter: { + id: { + in: [...activityIds].sort(sortByAscString), + }, + ...additionalFilter, + }, + }; + + const filteredActivities = [ + ...activities.filter( + (activity) => !shouldActivityBeExcluded?.(activity) ?? true, + ), + ].sort((a, b) => { + return a.createdAt > b.createdAt ? -1 : 1; + }); + + upsertFindManyActivitiesInCache({ + objectRecordsToOverwrite: filteredActivities, + queryVariables: { + ...nextFindManyActivitiesQueryFilter, + orderBy: { createdAt: 'DescNullsFirst' }, + }, + depth: FIND_MANY_ACTIVITIES_QUERY_KEY.depth, + queryFields: + FIND_MANY_ACTIVITIES_QUERY_KEY.fieldsFactory?.(objectMetadataItems), + computeReferences: true, + }); + }; + + return { + prepareFindManyActivitiesQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts new file mode 100644 index 000000000000..cbf0e2f419a3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts @@ -0,0 +1,49 @@ +import { useRecoilValue } from 'recoil'; + +import { usePrepareFindManyActivitiesQuery } from '@/activities/hooks/usePrepareFindManyActivitiesQuery'; +import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState'; +import { Activity } from '@/activities/types/Activity'; +import { isDefined } from '~/utils/isDefined'; + +// This hook should only be executed if the normalized cache is up-to-date +// It will take a targetableObject and prepare the queries for the activities +// based on the activityTargets of the targetableObject +export const useRefreshShowPageFindManyActivitiesQueries = () => { + const objectShowPageTargetableObject = useRecoilValue( + objectShowPageTargetableObjectState, + ); + + const { prepareFindManyActivitiesQuery } = + usePrepareFindManyActivitiesQuery(); + + const refreshShowPageFindManyActivitiesQueries = () => { + if (isDefined(objectShowPageTargetableObject)) { + prepareFindManyActivitiesQuery({ + targetableObject: objectShowPageTargetableObject, + }); + prepareFindManyActivitiesQuery({ + targetableObject: objectShowPageTargetableObject, + additionalFilter: { + completedAt: { is: 'NULL' }, + type: { eq: 'Task' }, + }, + shouldActivityBeExcluded: (activity: Activity) => { + return activity.type !== 'Task'; + }, + }); + prepareFindManyActivitiesQuery({ + targetableObject: objectShowPageTargetableObject, + additionalFilter: { + type: { eq: 'Note' }, + }, + shouldActivityBeExcluded: (activity: Activity) => { + return activity.type !== 'Note'; + }, + }); + } + }; + + return { + refreshShowPageFindManyActivitiesQueries, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts deleted file mode 100644 index e65b8058c051..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivitiesQueries.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; - -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { OrderByField } from '@/object-metadata/types/OrderByField'; -import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; -import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; -import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; -import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; -import { sortByAscString } from '~/utils/array/sortByAscString'; - -// TODO: improve, no bug if query to inject doesn't exist -export const useRemoveFromActivitiesQueries = () => { - const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.Activity, - }); - - const { - upsertFindManyRecordsQueryInCache: overwriteFindManyActivitiesInCache, - } = useUpsertFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivity, - }); - - const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - - const { - readFindManyRecordsQueryInCache: readFindManyActivityTargetsQueryInCache, - } = useReadFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const { - readFindManyRecordsQueryInCache: readFindManyActivitiesQueryInCache, - } = useReadFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivity, - }); - - const removeFromActivitiesQueries = ({ - activityIdToRemove, - targetableObjects, - activitiesFilters, - activitiesOrderByVariables, - }: { - activityIdToRemove: string; - targetableObjects: ActivityTargetableObject[]; - activitiesFilters?: ObjectRecordQueryFilter; - activitiesOrderByVariables?: OrderByField; - }) => { - const findManyActivitiyTargetsQueryFilter = getActivityTargetsFilter({ - targetableObjects, - }); - - const findManyActivityTargetsQueryVariables = { - filter: findManyActivitiyTargetsQueryFilter, - } as ObjectRecordQueryVariables; - - const existingActivityTargetsForTargetableObject = - readFindManyActivityTargetsQueryInCache({ - queryVariables: findManyActivityTargetsQueryVariables, - }); - - const existingActivityIds = existingActivityTargetsForTargetableObject - ?.map((activityTarget) => activityTarget.activityId) - .filter(isNonEmptyString); - - const currentFindManyActivitiesQueryVariables = { - filter: { - id: { - in: [...existingActivityIds].sort(sortByAscString), - }, - ...activitiesFilters, - }, - orderBy: activitiesOrderByVariables, - }; - - const existingActivities = readFindManyActivitiesQueryInCache({ - queryVariables: currentFindManyActivitiesQueryVariables, - }); - - if (!isNonEmptyArray(existingActivities)) { - return; - } - - const activityIdsAfterRemoval = existingActivityIds.filter( - (existingActivityId) => existingActivityId !== activityIdToRemove, - ); - - const nextFindManyActivitiesQueryVariables = { - filter: { - id: { - in: [...activityIdsAfterRemoval].sort(sortByAscString), - }, - ...activitiesFilters, - }, - orderBy: activitiesOrderByVariables, - }; - - const newActivities = existingActivities.filter( - (existingActivity) => existingActivity.id !== activityIdToRemove, - ); - - overwriteFindManyActivitiesInCache({ - objectRecordsToOverwrite: newActivities, - queryVariables: nextFindManyActivitiesQueryVariables, - }); - }; - - return { - removeFromActivitiesQueries, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivityTargetsQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivityTargetsQueries.ts deleted file mode 100644 index dd490f68514e..000000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useRemoveFromActivityTargetsQueries.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { isNonEmptyArray } from '@sniptt/guards'; - -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetsFilter } from '@/activities/utils/getActivityTargetsFilter'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; -import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; -import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; - -export const useRemoveFromActivityTargetsQueries = () => { - const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - - const { - readFindManyRecordsQueryInCache: readFindManyActivityTargetsQueryInCache, - } = useReadFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const { - upsertFindManyRecordsQueryInCache: - overwriteFindManyActivityTargetsQueryInCache, - } = useUpsertFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const removeFromActivityTargetsQueries = ({ - activityTargetsToRemove, - targetableObjects, - }: { - activityTargetsToRemove: ActivityTarget[]; - targetableObjects: ActivityTargetableObject[]; - }) => { - const findManyActivitiyTargetsQueryFilter = getActivityTargetsFilter({ - targetableObjects, - }); - - const findManyActivityTargetsQueryVariables = { - filter: findManyActivitiyTargetsQueryFilter, - } as ObjectRecordQueryVariables; - - const existingActivityTargetsForTargetableObject = - readFindManyActivityTargetsQueryInCache({ - queryVariables: findManyActivityTargetsQueryVariables, - }); - - const newActivityTargetsForTargetableObject = isNonEmptyArray( - activityTargetsToRemove, - ) - ? existingActivityTargetsForTargetableObject.filter( - (existingActivityTarget) => - activityTargetsToRemove.some( - (activityTargetToRemove) => - activityTargetToRemove.id !== existingActivityTarget.id, - ), - ) - : existingActivityTargetsForTargetableObject; - - overwriteFindManyActivityTargetsQueryInCache({ - objectRecordsToOverwrite: newActivityTargetsForTargetableObject, - queryVariables: findManyActivityTargetsQueryVariables, - }); - }; - - return { - removeFromActivityTargetsQueries, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts b/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts index 746735dad950..6365199216e3 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts @@ -1,24 +1,16 @@ -import { useLocation } from 'react-router-dom'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB'; -import { useInjectIntoActivitiesQueries } from '@/activities/hooks/useInjectIntoActivitiesQueries'; -import { useInjectIntoActivityTargetsQueries } from '@/activities/hooks/useInjectIntoActivityTargetsQueries'; -import { currentNotesQueryVariablesState } from '@/activities/notes/states/currentNotesQueryVariablesState'; +import { useRefreshShowPageFindManyActivitiesQueries } from '@/activities/hooks/useRefreshShowPageFindManyActivitiesQueries'; import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState'; import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; -import { currentCompletedTaskQueryVariablesState } from '@/activities/tasks/states/currentCompletedTaskQueryVariablesState'; -import { currentIncompleteTaskQueryVariablesState } from '@/activities/tasks/states/currentIncompleteTaskQueryVariablesState'; -import { useInjectIntoTimelineActivitiesQueries } from '@/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries'; import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState'; import { Activity } from '@/activities/types/Activity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; -// TODO: create a generic way to have records only in cache for create mode and delete them afterwards ? export const useUpsertActivity = () => { const [isActivityInCreateMode, setIsActivityInCreateMode] = useRecoilState( isActivityInCreateModeState, @@ -40,31 +32,8 @@ export const useUpsertActivity = () => { objectShowPageTargetableObjectState, ); - const { injectActivitiesQueries } = useInjectIntoActivitiesQueries(); - const { injectActivityTargetsQueries } = - useInjectIntoActivityTargetsQueries(); - - const { pathname } = useLocation(); - - const weAreOnObjectShowPage = pathname.startsWith('/object'); - const weAreOnTaskPage = pathname.startsWith('/tasks'); - - const { injectIntoTimelineActivitiesQueries } = - useInjectIntoTimelineActivitiesQueries(); - - const { makeActivityWithConnection } = useActivityConnectionUtils(); - - const currentCompletedTaskQueryVariables = useRecoilValue( - currentCompletedTaskQueryVariablesState, - ); - - const currentIncompleteTaskQueryVariables = useRecoilValue( - currentIncompleteTaskQueryVariablesState, - ); - - const currentNotesQueryVariables = useRecoilValue( - currentNotesQueryVariablesState, - ); + const { refreshShowPageFindManyActivitiesQueries } = + useRefreshShowPageFindManyActivitiesQueries(); const upsertActivity = async ({ activity, @@ -74,103 +43,19 @@ export const useUpsertActivity = () => { input: Partial; }) => { setIsUpsertingActivityInDB(true); - if (isActivityInCreateMode) { const activityToCreate: Activity = { ...activity, ...input, }; - const { activityWithConnection } = - makeActivityWithConnection(activityToCreate); - - if (weAreOnTaskPage) { - if (isNonNullable(activityWithConnection.completedAt)) { - injectActivitiesQueries({ - activitiesFilters: currentCompletedTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentCompletedTaskQueryVariables?.orderBy, - activityTargetsToInject: activityToCreate.activityTargets, - activityToInject: activityWithConnection, - targetableObjects: [], - }); - } else { - injectActivitiesQueries({ - activitiesFilters: currentIncompleteTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentIncompleteTaskQueryVariables?.orderBy, - activityTargetsToInject: activityToCreate.activityTargets, - activityToInject: activityWithConnection, - targetableObjects: [], - }); - } - - injectActivityTargetsQueries({ - activityTargetsToInject: activityToCreate.activityTargets, - targetableObjects: [], - }); - } - - // Call optimistic effects - if (weAreOnObjectShowPage && objectShowPageTargetableObject) { - injectIntoTimelineActivitiesQueries({ - timelineTargetableObject: objectShowPageTargetableObject, - activityToInject: activityWithConnection, - activityTargetsToInject: activityToCreate.activityTargets, - }); - - const injectOnlyInIdFilterForTaskQueries = - activityWithConnection.type !== 'Task'; - - const injectOnlyInIdFilterForNotesQueries = - activityWithConnection.type !== 'Note'; - - if (isNonNullable(currentCompletedTaskQueryVariables)) { - injectActivitiesQueries({ - activitiesFilters: currentCompletedTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentCompletedTaskQueryVariables?.orderBy, - activityTargetsToInject: activityToCreate.activityTargets, - activityToInject: activityWithConnection, - targetableObjects: [objectShowPageTargetableObject], - injectOnlyInIdFilter: injectOnlyInIdFilterForTaskQueries, - }); - } - - if (isNonNullable(currentIncompleteTaskQueryVariables)) { - injectActivitiesQueries({ - activitiesFilters: - currentIncompleteTaskQueryVariables?.filter ?? {}, - activitiesOrderByVariables: - currentIncompleteTaskQueryVariables?.orderBy ?? {}, - activityTargetsToInject: activityToCreate.activityTargets, - activityToInject: activityWithConnection, - targetableObjects: [objectShowPageTargetableObject], - injectOnlyInIdFilter: injectOnlyInIdFilterForTaskQueries, - }); - } - - if (isNonNullable(currentNotesQueryVariables)) { - injectActivitiesQueries({ - activitiesFilters: currentNotesQueryVariables?.filter, - activitiesOrderByVariables: currentNotesQueryVariables?.orderBy, - activityTargetsToInject: activityToCreate.activityTargets, - activityToInject: activityWithConnection, - targetableObjects: [objectShowPageTargetableObject], - injectOnlyInIdFilter: injectOnlyInIdFilterForNotesQueries, - }); - } - - injectActivityTargetsQueries({ - activityTargetsToInject: activityToCreate.activityTargets, - targetableObjects: [objectShowPageTargetableObject], - }); + if (isDefined(objectShowPageTargetableObject)) { + refreshShowPageFindManyActivitiesQueries(); } await createActivityInDB(activityToCreate); setActivityIdInDrawer(activityToCreate.id); - setIsActivityInCreateMode(false); } else { await updateOneActivity?.({ 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 3275f09233ee..16a51164a88f 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 @@ -1,23 +1,25 @@ import styled from '@emotion/styled'; -import { isNonEmptyArray } from '@sniptt/guards'; -import { useRecoilState } from 'recoil'; +import { isNonEmptyArray, isNull } from '@sniptt/guards'; +import { useRecoilState, useSetRecoilState } from 'recoil'; import { v4 } from 'uuid'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; -import { useInjectIntoActivityTargetInlineCellCache } from '@/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache'; import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetWithTargetRecord } from '@/activities/types/ActivityTargetObject'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; +import { getActivityTargetObjectFieldName } from '@/activities/utils/getActivityTargetObjectFieldName'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; +import { useCreateManyRecordsInCache } from '@/object-record/cache/hooks/useCreateManyRecordsInCache'; import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { MultipleObjectRecordSelect } from '@/object-record/relation-picker/components/MultipleObjectRecordSelect'; import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { prefillRecord } from '@/object-record/utils/prefillRecord'; const StyledSelectContainer = styled.div` left: 0px; @@ -38,7 +40,7 @@ export const ActivityTargetInlineCellEditMode = ({ const selectedTargetObjectIds = activityTargetWithTargetRecords.map( (activityTarget) => ({ - objectNameSingular: activityTarget.targetObjectNameSingular, + objectNameSingular: activityTarget.targetObjectMetadataItem.nameSingular, id: activityTarget.targetObject.id, }), ); @@ -59,16 +61,17 @@ export const ActivityTargetInlineCellEditMode = ({ const { upsertActivity } = useUpsertActivity(); const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItemOnly({ + useObjectMetadataItem({ objectNameSingular: CoreObjectNameSingular.ActivityTarget, }); - const { injectIntoActivityTargetInlineCellCache } = - useInjectIntoActivityTargetInlineCellCache(); + const setActivityFromStore = useSetRecoilState( + recordStoreFamilyState(activity.id), + ); - const { generateObjectRecordOptimisticResponse } = - useGenerateObjectRecordOptimisticResponse({ - objectMetadataItem: objectMetadataItemActivityTarget, + const { createManyRecordsInCache: createManyActivityTargetsInCache } = + useCreateManyRecordsInCache({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, }); const handleSubmit = async (selectedRecords: ObjectRecordForSelect[]) => { @@ -100,17 +103,22 @@ export const ActivityTargetInlineCellEditMode = ({ const activityTargetsToCreate = selectedTargetObjectsToCreate.map( (selectedRecord) => { - const emptyActivityTarget = - generateObjectRecordOptimisticResponse({ + const emptyActivityTarget = prefillRecord({ + objectMetadataItem: objectMetadataItemActivityTarget, + input: { id: v4(), activityId: activity.id, activity, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + [getActivityTargetObjectFieldName({ + nameSingular: selectedRecord.objectMetadataItem.nameSingular, + })]: selectedRecord.record, [getActivityTargetObjectFieldIdName({ nameSingular: selectedRecord.objectMetadataItem.nameSingular, })]: selectedRecord.recordIdentifier.id, - }); + }, + }); return emptyActivityTarget; }, @@ -128,12 +136,8 @@ export const ActivityTargetInlineCellEditMode = ({ ); } - injectIntoActivityTargetInlineCellCache({ - activityId: activity.id, - activityTargetsToInject: activityTargetsAfterUpdate, - }); - if (isActivityInCreateMode) { + createManyActivityTargetsInCache(activityTargetsToCreate); upsertActivity({ activity, input: { @@ -142,9 +146,7 @@ export const ActivityTargetInlineCellEditMode = ({ }); } else { if (activityTargetsToCreate.length > 0) { - await createManyActivityTargets(activityTargetsToCreate, { - skipOptimisticEffect: true, - }); + await createManyActivityTargets(activityTargetsToCreate); } if (activityTargetsToDelete.length > 0) { @@ -153,12 +155,20 @@ export const ActivityTargetInlineCellEditMode = ({ (activityTargetObjectRecord) => activityTargetObjectRecord.activityTarget.id, ), - { - skipOptimisticEffect: true, - }, ); } } + + setActivityFromStore((currentActivity) => { + if (isNull(currentActivity)) { + return null; + } + + return { + ...currentActivity, + activityTargets: activityTargetsAfterUpdate, + }; + }); }; const handleCancel = () => { diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx index d1b09c737255..0ba763525629 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetsInlineCell.tsx @@ -1,4 +1,5 @@ import { Key } from 'ts-key-enum'; +import { IconArrowUpRight, IconPencil } from 'twenty-ui'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; @@ -8,7 +9,6 @@ import { ActivityEditorHotkeyScope } from '@/activities/types/ActivityEditorHotk import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; import { RecordInlineCellContainer } from '@/object-record/record-inline-cell/components/RecordInlineCellContainer'; import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; -import { IconArrowUpRight, IconPencil } from '@/ui/display/icon'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; type ActivityTargetsInlineCellProps = { @@ -18,9 +18,8 @@ type ActivityTargetsInlineCellProps = { export const ActivityTargetsInlineCell = ({ activity, }: ActivityTargetsInlineCellProps) => { - const { activityTargetObjectRecords } = useActivityTargetObjectRecords({ - activityId: activity?.id ?? '', - }); + const { activityTargetObjectRecords } = + useActivityTargetObjectRecords(activity); const { closeInlineCell } = useInlineCell(); useScopedHotkeys( diff --git a/packages/twenty-front/src/modules/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache.ts b/packages/twenty-front/src/modules/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache.ts deleted file mode 100644 index d3c52f1bf38a..000000000000 --- a/packages/twenty-front/src/modules/activities/inline-cell/hooks/useInjectIntoActivityTargetInlineCellCache.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; - -export const useInjectIntoActivityTargetInlineCellCache = () => { - const { objectMetadataItem: objectMetadataItemActivityTarget } = - useObjectMetadataItemOnly({ - objectNameSingular: CoreObjectNameSingular.ActivityTarget, - }); - - const { - upsertFindManyRecordsQueryInCache: - overwriteFindManyActivityTargetsQueryInCache, - } = useUpsertFindManyRecordsQueryInCache({ - objectMetadataItem: objectMetadataItemActivityTarget, - }); - - const injectIntoActivityTargetInlineCellCache = ({ - activityId, - activityTargetsToInject, - }: { - activityId: string; - activityTargetsToInject: ActivityTarget[]; - }) => { - const activityTargetInlineCellQueryVariables = { - filter: { - activityId: { - eq: activityId, - }, - }, - }; - - overwriteFindManyActivityTargetsQueryInCache({ - queryVariables: activityTargetInlineCellQueryVariables, - objectRecordsToOverwrite: activityTargetsToInject, - }); - }; - - return { - injectIntoActivityTargetInlineCellCache, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx b/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx index 323546127a24..3ae87c635959 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/NoteCard.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconComment } from 'twenty-ui'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell'; @@ -10,7 +11,6 @@ import { FieldContext, GenericFieldContextType, } from '@/object-record/record-field/contexts/FieldContext'; -import { IconComment } from '@/ui/display/icon'; const StyledCard = styled.div<{ isSingleNote: boolean }>` align-items: flex-start; 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 120ac4c63b1f..88956be73326 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; +import { IconPlus } from 'twenty-ui'; 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 { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { diff --git a/packages/twenty-front/src/modules/activities/notes/hooks/__tests__/useNotes.test.ts b/packages/twenty-front/src/modules/activities/notes/hooks/__tests__/useNotes.test.ts new file mode 100644 index 000000000000..f64b745f0402 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/notes/hooks/__tests__/useNotes.test.ts @@ -0,0 +1,45 @@ +import { renderHook } from '@testing-library/react'; + +import { useNotes } from '@/activities/notes/hooks/useNotes'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; + +jest.mock('@/activities/hooks/useActivities', () => ({ + useActivities: jest.fn(() => ({ + activities: [{ id: '1', content: 'Example Note' }], + initialized: true, + loading: false, + })), +})); + +jest.mock('recoil', () => { + const actualRecoil = jest.requireActual('recoil'); + return { + ...actualRecoil, + useRecoilState: jest.fn(() => { + const mockCurrentNotesQueryVariables = { + filter: { + type: { eq: 'Note' }, + }, + orderBy: 'mockOrderBy', + }; + return [mockCurrentNotesQueryVariables, jest.fn()]; + }), + atom: jest.fn(), + }; +}); + +describe('useNotes', () => { + it('should return notes, initialized, and loading as expected', () => { + const mockTargetableObject: ActivityTargetableObject = { + id: '1', + targetObjectNameSingular: 'Example Target', + }; + const { result } = renderHook(() => useNotes(mockTargetableObject)); + + expect(result.current.notes).toEqual([ + { id: '1', content: 'Example Note' }, + ]); + expect(result.current.initialized).toBe(true); + expect(result.current.loading).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/query-keys/CreateOneActivityQueryKey.ts b/packages/twenty-front/src/modules/activities/query-keys/CreateOneActivityQueryKey.ts new file mode 100644 index 000000000000..acec84ba75f3 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/query-keys/CreateOneActivityQueryKey.ts @@ -0,0 +1,34 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { QueryKey } from '@/object-record/query-keys/types/QueryKey'; + +export const CREATE_ONE_ACTIVITY_QUERY_KEY: QueryKey = { + objectNameSingular: CoreObjectNameSingular.Activity, + variables: {}, + fields: { + id: true, + __typename: true, + createdAt: true, + updatedAt: true, + author: { + id: true, + name: true, + __typename: true, + }, + authorId: true, + assigneeId: true, + assignee: { + id: true, + name: true, + __typename: true, + }, + comments: true, + attachments: true, + body: true, + title: true, + completedAt: true, + dueAt: true, + reminderAt: true, + type: true, + }, + depth: 1, +}; diff --git a/packages/twenty-front/src/modules/activities/query-keys/FindManyActivitiesQueryKey.ts b/packages/twenty-front/src/modules/activities/query-keys/FindManyActivitiesQueryKey.ts new file mode 100644 index 000000000000..09e6b04d6440 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/query-keys/FindManyActivitiesQueryKey.ts @@ -0,0 +1,38 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { QueryKey } from '@/object-record/query-keys/types/QueryKey'; + +export const FIND_MANY_ACTIVITIES_QUERY_KEY: QueryKey = { + objectNameSingular: CoreObjectNameSingular.Activity, + variables: {}, + fieldsFactory: (_objectMetadataItems: ObjectMetadataItem[]) => { + return { + id: true, + __typename: true, + createdAt: true, + updatedAt: true, + author: { + id: true, + name: true, + __typename: true, + }, + authorId: true, + assigneeId: true, + assignee: { + id: true, + name: true, + __typename: true, + }, + comments: true, + attachments: true, + body: true, + title: true, + completedAt: true, + dueAt: true, + reminderAt: true, + type: true, + activityTargets: true, + }; + }, + depth: 2, +}; diff --git a/packages/twenty-front/src/modules/activities/query-keys/FindManyActivityTargetsQueryKey.ts b/packages/twenty-front/src/modules/activities/query-keys/FindManyActivityTargetsQueryKey.ts new file mode 100644 index 000000000000..b0d34ff84a88 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/query-keys/FindManyActivityTargetsQueryKey.ts @@ -0,0 +1,21 @@ +import { generateActivityTargetMorphFieldKeys } from '@/activities/utils/generateActivityTargetMorphFieldKeys'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { QueryKey } from '@/object-record/query-keys/types/QueryKey'; + +export const FIND_MANY_ACTIVITY_TARGETS_QUERY_KEY: QueryKey = { + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + variables: {}, + fieldsFactory: (objectMetadataItems: ObjectMetadataItem[]) => { + return { + id: true, + __typename: true, + createdAt: true, + updatedAt: true, + activity: true, + activityId: true, + ...generateActivityTargetMorphFieldKeys(objectMetadataItems), + }; + }, + depth: 1, +}; diff --git a/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx b/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx index 397255487d0e..c374ab1e130d 100644 --- a/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx +++ b/packages/twenty-front/src/modules/activities/right-drawer/components/ActivityActionBar.tsx @@ -1,34 +1,29 @@ -import { useLocation } from 'react-router-dom'; import styled from '@emotion/styled'; -import { isNonEmptyArray } from '@sniptt/guards'; +import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; +import { IconPlus, IconTrash } from 'twenty-ui'; -import { useDeleteActivityFromCache } from '@/activities/hooks/useDeleteActivityFromCache'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; -import { useRemoveFromActivitiesQueries } from '@/activities/hooks/useRemoveFromActivitiesQueries'; -import { useRemoveFromActivityTargetsQueries } from '@/activities/hooks/useRemoveFromActivityTargetsQueries'; -import { currentNotesQueryVariablesState } from '@/activities/notes/states/currentNotesQueryVariablesState'; +import { useRefreshShowPageFindManyActivitiesQueries } from '@/activities/hooks/useRefreshShowPageFindManyActivitiesQueries'; import { activityIdInDrawerState } from '@/activities/states/activityIdInDrawerState'; import { activityTargetableEntityArrayState } from '@/activities/states/activityTargetableEntityArrayState'; import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; import { temporaryActivityForEditorState } from '@/activities/states/temporaryActivityForEditorState'; import { viewableActivityIdState } from '@/activities/states/viewableActivityIdState'; -import { currentCompletedTaskQueryVariablesState } from '@/activities/tasks/states/currentCompletedTaskQueryVariablesState'; -import { currentIncompleteTaskQueryVariablesState } from '@/activities/tasks/states/currentIncompleteTaskQueryVariablesState'; -import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline/constants/FindManyTimelineActivitiesOrderBy'; import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState'; import { Activity } from '@/activities/types/Activity'; +import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useDeleteRecordFromCache } from '@/object-record/cache/hooks/useDeleteRecordFromCache'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { getChildRelationArray } from '@/object-record/utils/getChildRelationArray'; import { mapToRecordId } from '@/object-record/utils/mapToObjectId'; -import { IconPlus, IconTrash } from '@/ui/display/icon'; import { IconButton } from '@/ui/input/button/components/IconButton'; import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; const StyledButtonContainer = styled.div` display: inline-flex; @@ -56,7 +51,12 @@ export const ActivityActionBar = () => { const [temporaryActivityForEditor, setTemporaryActivityForEditor] = useRecoilState(temporaryActivityForEditorState); - const { deleteActivityFromCache } = useDeleteActivityFromCache(); + const deleteActivityFromCache = useDeleteRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.Activity, + }); + const deleteActivityTargetFromCache = useDeleteRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.ActivityTarget, + }); const [isActivityInCreateMode] = useRecoilState(isActivityInCreateModeState); const [isUpsertingActivityInDB] = useRecoilState( @@ -67,27 +67,10 @@ export const ActivityActionBar = () => { objectShowPageTargetableObjectState, ); - const openCreateActivity = useOpenCreateActivityDrawer(); - - const currentCompletedTaskQueryVariables = useRecoilValue( - currentCompletedTaskQueryVariablesState, - ); - - const currentIncompleteTaskQueryVariables = useRecoilValue( - currentIncompleteTaskQueryVariablesState, - ); + const { refreshShowPageFindManyActivitiesQueries } = + useRefreshShowPageFindManyActivitiesQueries(); - const currentNotesQueryVariables = useRecoilValue( - currentNotesQueryVariablesState, - ); - - const { pathname } = useLocation(); - const { removeFromActivitiesQueries } = useRemoveFromActivitiesQueries(); - const { removeFromActivityTargetsQueries } = - useRemoveFromActivityTargetsQueries(); - - const weAreOnObjectShowPage = pathname.startsWith('/object'); - const weAreOnTaskPage = pathname.startsWith('/tasks'); + const openCreateActivity = useOpenCreateActivityDrawer(); const deleteActivity = useRecoilCallback( ({ snapshot }) => @@ -108,112 +91,46 @@ export const ActivityActionBar = () => { setIsRightDrawerOpen(false); - if (viewableActivityId) { - if ( - isActivityInCreateMode && - isNonNullable(temporaryActivityForEditor) - ) { - deleteActivityFromCache(temporaryActivityForEditor); - setTemporaryActivityForEditor(null); - } else { - if (activityIdInDrawer) { - const activityTargetIdsToDelete: string[] = - activityTargets.map(mapToRecordId) ?? []; - - if (weAreOnTaskPage) { - removeFromActivitiesQueries({ - activityIdToRemove: viewableActivityId, - targetableObjects: [], - activitiesFilters: currentCompletedTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentCompletedTaskQueryVariables?.orderBy, - }); - - removeFromActivitiesQueries({ - activityIdToRemove: viewableActivityId, - targetableObjects: [], - activitiesFilters: - currentIncompleteTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentIncompleteTaskQueryVariables?.orderBy, - }); - } else if ( - weAreOnObjectShowPage && - isNonNullable(objectShowPageTargetableObject) - ) { - removeFromActivitiesQueries({ - activityIdToRemove: viewableActivityId, - targetableObjects: [objectShowPageTargetableObject], - activitiesFilters: {}, - activitiesOrderByVariables: - FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, - }); - - if (isNonNullable(currentCompletedTaskQueryVariables)) { - removeFromActivitiesQueries({ - activityIdToRemove: viewableActivityId, - targetableObjects: [objectShowPageTargetableObject], - activitiesFilters: - currentCompletedTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentCompletedTaskQueryVariables?.orderBy, - }); - } + if (!isNonEmptyString(viewableActivityId)) { + return; + } - if (isNonNullable(currentIncompleteTaskQueryVariables)) { - removeFromActivitiesQueries({ - activityIdToRemove: viewableActivityId, - targetableObjects: [objectShowPageTargetableObject], - activitiesFilters: - currentIncompleteTaskQueryVariables?.filter, - activitiesOrderByVariables: - currentIncompleteTaskQueryVariables?.orderBy, - }); - } + if (isActivityInCreateMode && isDefined(temporaryActivityForEditor)) { + deleteActivityFromCache(temporaryActivityForEditor); + setTemporaryActivityForEditor(null); + return; + } - if (isNonNullable(currentNotesQueryVariables)) { - removeFromActivitiesQueries({ - activityIdToRemove: viewableActivityId, - targetableObjects: [objectShowPageTargetableObject], - activitiesFilters: currentNotesQueryVariables?.filter, - activitiesOrderByVariables: - currentNotesQueryVariables?.orderBy, - }); - } + if (isNonEmptyString(activityIdInDrawer)) { + const activityTargetIdsToDelete: string[] = + activityTargets.map(mapToRecordId) ?? []; - removeFromActivityTargetsQueries({ - activityTargetsToRemove: activity?.activityTargets ?? [], - targetableObjects: [objectShowPageTargetableObject], - }); - } + deleteActivityFromCache(activity); + activityTargets.forEach((activityTarget: ActivityTarget) => { + deleteActivityTargetFromCache(activityTarget); + }); - if (isNonEmptyArray(activityTargetIdsToDelete)) { - await deleteManyActivityTargets(activityTargetIdsToDelete); - } + refreshShowPageFindManyActivitiesQueries(); - await deleteOneActivity?.(viewableActivityId); - } + if (isNonEmptyArray(activityTargetIdsToDelete)) { + await deleteManyActivityTargets(activityTargetIdsToDelete); } + + await deleteOneActivity?.(viewableActivityId); } }, [ activityIdInDrawer, - currentCompletedTaskQueryVariables, - currentIncompleteTaskQueryVariables, - currentNotesQueryVariables, - deleteActivityFromCache, - deleteManyActivityTargets, - deleteOneActivity, + setIsRightDrawerOpen, + viewableActivityId, isActivityInCreateMode, - objectShowPageTargetableObject, - removeFromActivitiesQueries, - removeFromActivityTargetsQueries, - setTemporaryActivityForEditor, temporaryActivityForEditor, - viewableActivityId, - weAreOnObjectShowPage, - weAreOnTaskPage, - setIsRightDrawerOpen, + deleteActivityFromCache, + setTemporaryActivityForEditor, + refreshShowPageFindManyActivitiesQueries, + deleteOneActivity, + deleteActivityTargetFromCache, + deleteManyActivityTargets, ], ); @@ -223,10 +140,10 @@ export const ActivityActionBar = () => { const addActivity = () => { setIsRightDrawerOpen(false); - if (record && objectShowPageTargetableObject) { + if (isDefined(record) && isDefined(objectShowPageTargetableObject)) { openCreateActivity({ - type: record.type, - customAssignee: record.assignee, + type: record?.type, + customAssignee: record?.assignee, targetableObjects: activityTargetableEntityArray, }); } diff --git a/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivityTopBar.tsx b/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivityTopBar.tsx index 628fc3893d8f..2e3c35fe4cf2 100644 --- a/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivityTopBar.tsx +++ b/packages/twenty-front/src/modules/activities/right-drawer/components/RightDrawerActivityTopBar.tsx @@ -1,17 +1,20 @@ import styled from '@emotion/styled'; import { ActivityActionBar } from '@/activities/right-drawer/components/ActivityActionBar'; +import { RightDrawerTopBarCloseButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarCloseButton'; +import { RightDrawerTopBarExpandButton } from '@/ui/layout/right-drawer/components/RightDrawerTopBarExpandButton'; import { StyledRightDrawerTopBar } from '@/ui/layout/right-drawer/components/StyledRightDrawerTopBar'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; -import { RightDrawerTopBarCloseButton } from '../../../ui/layout/right-drawer/components/RightDrawerTopBarCloseButton'; -import { RightDrawerTopBarExpandButton } from '../../../ui/layout/right-drawer/components/RightDrawerTopBarExpandButton'; +type RightDrawerActivityTopBarProps = { showActionBar?: boolean }; const StyledTopBarWrapper = styled.div` display: flex; `; -export const RightDrawerActivityTopBar = () => { +export const RightDrawerActivityTopBar = ({ + showActionBar = true, +}: RightDrawerActivityTopBarProps) => { const isMobile = useIsMobile(); return ( @@ -20,7 +23,7 @@ export const RightDrawerActivityTopBar = () => { {!isMobile && } - + {showActionBar && } ); }; diff --git a/packages/twenty-front/src/modules/activities/states/activityBodyFamilyState.ts b/packages/twenty-front/src/modules/activities/states/activityBodyFamilyState.ts index 452dc0220182..b9d5579573a7 100644 --- a/packages/twenty-front/src/modules/activities/states/activityBodyFamilyState.ts +++ b/packages/twenty-front/src/modules/activities/states/activityBodyFamilyState.ts @@ -1,9 +1,9 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const activityBodyFamilyState = atomFamily< +export const activityBodyFamilyState = createFamilyState< string, { activityId: string } >({ key: 'activityBodyFamilyState', - default: '', + defaultValue: '', }); diff --git a/packages/twenty-front/src/modules/activities/states/activityIdInDrawerState.ts b/packages/twenty-front/src/modules/activities/states/activityIdInDrawerState.ts index 7bc9362fc225..8c2e46d9eb19 100644 --- a/packages/twenty-front/src/modules/activities/states/activityIdInDrawerState.ts +++ b/packages/twenty-front/src/modules/activities/states/activityIdInDrawerState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const activityIdInDrawerState = atom({ +export const activityIdInDrawerState = createState({ key: 'activityIdInDrawerState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts b/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts index c3a06d48f821..0d6e06ab4683 100644 --- a/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts +++ b/packages/twenty-front/src/modules/activities/states/activityTargetableEntityArrayState.ts @@ -1,10 +1,10 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; -export const activityTargetableEntityArrayState = atom< +export const activityTargetableEntityArrayState = createState< ActivityTargetableObject[] >({ key: 'activities/targetable-entity-array', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/activities/states/activityTitleFamilyState.ts b/packages/twenty-front/src/modules/activities/states/activityTitleFamilyState.ts index 6196bae01d03..5a02e57fcb75 100644 --- a/packages/twenty-front/src/modules/activities/states/activityTitleFamilyState.ts +++ b/packages/twenty-front/src/modules/activities/states/activityTitleFamilyState.ts @@ -1,9 +1,9 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const activityTitleFamilyState = atomFamily< +export const activityTitleFamilyState = createFamilyState< string, { activityId: string } >({ key: 'activityTitleFamilyState', - default: '', + defaultValue: '', }); diff --git a/packages/twenty-front/src/modules/activities/states/activityTitleHasBeenSetFamilyState.ts b/packages/twenty-front/src/modules/activities/states/activityTitleHasBeenSetFamilyState.ts index 096df500dd5e..f47e13f84c43 100644 --- a/packages/twenty-front/src/modules/activities/states/activityTitleHasBeenSetFamilyState.ts +++ b/packages/twenty-front/src/modules/activities/states/activityTitleHasBeenSetFamilyState.ts @@ -1,9 +1,9 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const activityTitleHasBeenSetFamilyState = atomFamily< +export const activityTitleHasBeenSetFamilyState = createFamilyState< boolean, { activityId: string } >({ key: 'activityTitleHasBeenSetFamilyState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts b/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts index 6d10623714ae..5da7f60e1c66 100644 --- a/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts +++ b/packages/twenty-front/src/modules/activities/states/canCreateActivityState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const canCreateActivityState = atom({ +export const canCreateActivityState = createState({ key: 'canCreateActivityState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts b/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts index d0f32a48374f..61aaf33d36db 100644 --- a/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts +++ b/packages/twenty-front/src/modules/activities/states/isActivityInCreateModeState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isActivityInCreateModeState = atom({ +export const isActivityInCreateModeState = createState({ key: 'isActivityInCreateModeState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts b/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts index 315b623bb24f..278b2430c172 100644 --- a/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts +++ b/packages/twenty-front/src/modules/activities/states/isCreatingActivityInDBState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isUpsertingActivityInDBState = atom({ +export const isUpsertingActivityInDBState = createState({ key: 'isUpsertingActivityInDBState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts b/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts index 553c7511088d..61d7ee87b246 100644 --- a/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts +++ b/packages/twenty-front/src/modules/activities/states/targetableObjectsInDrawerState.ts @@ -1,8 +1,10 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -export const targetableObjectsInDrawerState = atom({ +export const targetableObjectsInDrawerState = createState< + ActivityTargetableObject[] +>({ key: 'targetableObjectsInDrawerState', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/activities/states/temporaryActivityForEditorState.ts b/packages/twenty-front/src/modules/activities/states/temporaryActivityForEditorState.ts index 5b657a369885..b92ba320e920 100644 --- a/packages/twenty-front/src/modules/activities/states/temporaryActivityForEditorState.ts +++ b/packages/twenty-front/src/modules/activities/states/temporaryActivityForEditorState.ts @@ -1,8 +1,9 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { ActivityForEditor } from '@/activities/types/ActivityForEditor'; -export const temporaryActivityForEditorState = atom({ - key: 'temporaryActivityForEditorState', - default: null, -}); +export const temporaryActivityForEditorState = + createState({ + key: 'temporaryActivityForEditorState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/activities/states/viewableActivityIdState.ts b/packages/twenty-front/src/modules/activities/states/viewableActivityIdState.ts index 95cfc1712bff..393a2f96aaa5 100644 --- a/packages/twenty-front/src/modules/activities/states/viewableActivityIdState.ts +++ b/packages/twenty-front/src/modules/activities/states/viewableActivityIdState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const viewableActivityIdState = atom({ +export const viewableActivityIdState = createState({ key: 'activities/viewable-activity-id', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/activities/table/components/CommentChip.tsx b/packages/twenty-front/src/modules/activities/table/components/CommentChip.tsx index 2c64c650d810..66b30d107843 100644 --- a/packages/twenty-front/src/modules/activities/table/components/CommentChip.tsx +++ b/packages/twenty-front/src/modules/activities/table/components/CommentChip.tsx @@ -1,7 +1,6 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; - -import { IconComment } from '@/ui/display/icon'; +import { IconComment } from 'twenty-ui'; export type CommentChipProps = { count: number; 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 3c02ddb290b0..8dfacead3ca6 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx @@ -1,8 +1,8 @@ import { isNonEmptyArray } from '@sniptt/guards'; +import { IconPlus } from 'twenty-ui'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; export const AddTaskButton = ({ diff --git a/packages/twenty-front/src/modules/activities/tasks/components/CurrentUserDueTaskCountEffect.tsx b/packages/twenty-front/src/modules/activities/tasks/components/CurrentUserDueTaskCountEffect.tsx index f725249b40ac..802e03bb6b9d 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/CurrentUserDueTaskCountEffect.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/CurrentUserDueTaskCountEffect.tsx @@ -3,6 +3,7 @@ import { DateTime } from 'luxon'; import { useRecoilState, useRecoilValue } from 'recoil'; import { currentUserDueTaskCountState } from '@/activities/tasks/states/currentUserTaskCountState'; +import { Activity } from '@/activities/types/Activity'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -15,8 +16,9 @@ export const CurrentUserDueTaskCountEffect = () => { currentUserDueTaskCountState, ); - const { records: tasks } = useFindManyRecords({ + const { records: tasks } = useFindManyRecords({ objectNameSingular: CoreObjectNameSingular.Activity, + depth: 0, filter: { type: { eq: 'Task' }, completedAt: { is: 'NULL' }, 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 bdc9b1c17049..4a52b3969204 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx @@ -1,11 +1,11 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { IconPlus } from 'twenty-ui'; 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 { IconPlus } from '@/ui/display/icon'; import { Button } from '@/ui/input/button/components/Button'; import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { @@ -48,8 +48,8 @@ export const TaskGroups = ({ const openCreateActivity = useOpenCreateActivityDrawer(); - const { getActiveTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID); - const activeTabId = useRecoilValue(getActiveTabIdState()); + const { activeTabIdState } = useTabList(TASKS_TAB_LIST_COMPONENT_ID); + const activeTabId = useRecoilValue(activeTabIdState); if (!initialized) { return <>; 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 b5e1501a190f..e8655637adc8 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx @@ -1,12 +1,12 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconCalendar, IconComment } from 'twenty-ui'; import { ActivityTargetChips } from '@/activities/components/ActivityTargetChips'; import { useActivityTargetObjectRecords } from '@/activities/hooks/useActivityTargetObjectRecords'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { Activity } from '@/activities/types/Activity'; import { getActivitySummary } from '@/activities/utils/getActivitySummary'; -import { IconCalendar, IconComment } from '@/ui/display/icon'; import { OverflowingTextWithTooltip } from '@/ui/display/tooltip/OverflowingTextWithTooltip'; import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox'; import { beautifyExactDate, hasDatePassed } from '~/utils/date-utils'; @@ -78,9 +78,7 @@ export const TaskRow = ({ task }: { task: Activity }) => { const body = getActivitySummary(task.body); const { completeTask } = useCompleteTask(task); - const { activityTargetObjectRecords } = useActivityTargetObjectRecords({ - activityId: task.id, - }); + const { activityTargetObjectRecords } = useActivityTargetObjectRecords(task); return ( mockedDate); +global.Date.prototype.toISOString = toISOStringMock; + +const mocks: MockedResponse[] = [ + { + request: { + query: gql` + mutation UpdateOneActivity( + $idToUpdate: UUID! + $input: ActivityUpdateInput! + ) { + updateActivity(id: $idToUpdate, data: $input) { + __typename + createdAt + reminderAt + authorId + title + completedAt + updatedAt + body + dueAt + type + id + assigneeId + } + } + `, + variables: { + idToUpdate: task.id, + input: { completedAt: task.completedAt }, + }, + }, + result: jest.fn(() => ({ + data: { + updateActivity: { + __typename: 'Activity', + createdAt: '2024-03-15T07:33:14.212Z', + reminderAt: null, + authorId: '123', + title: 'Test', + completedAt: '2024-03-15T07:33:14.212Z', + updatedAt: '2024-03-15T07:33:14.212Z', + body: 'Test', + dueAt: '2024-03-15T07:33:14.212Z', + type: 'Task', + id: '123', + assigneeId: '123', + }, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +describe('useCompleteTask', () => { + it('should complete task', async () => { + const { result } = renderHook(() => useCompleteTask(task), { + wrapper: Wrapper, + }); + + await act(async () => { + await result.current.completeTask(true); + }); + + expect(mocks[0].result).toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCurrentUserTaskCount.test.tsx b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCurrentUserTaskCount.test.tsx new file mode 100644 index 000000000000..7d035958c4e4 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCurrentUserTaskCount.test.tsx @@ -0,0 +1,31 @@ +import { ReactNode } from 'react'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useCurrentUserTaskCount } from '@/activities/tasks/hooks/useCurrentUserDueTaskCount'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { mockedActivities } from '~/testing/mock-data/activities'; + +const useFindManyRecordsMock = jest.fn(() => ({ + records: [...mockedActivities, { id: '2' }], +})); + +jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ + useFindManyRecords: jest.fn(), +})); + +(useFindManyRecords as jest.Mock).mockImplementation(useFindManyRecordsMock); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useCurrentUserTaskCount', () => { + it('should return the current user task count', async () => { + const { result } = renderHook(() => useCurrentUserTaskCount(), { + wrapper: Wrapper, + }); + + expect(result.current.currentUserDueTaskCount).toBe(1); + }); +}); diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useTasks.test.tsx b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useTasks.test.tsx new file mode 100644 index 000000000000..c7171a76ae0c --- /dev/null +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useTasks.test.tsx @@ -0,0 +1,85 @@ +import { ReactNode } from 'react'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useActivities } from '@/activities/hooks/useActivities'; +import { useTasks } from '@/activities/tasks/hooks/useTasks'; +import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; + +const completedTasks = [ + { + id: '1', + completedAt: '2024-03-15T07:33:14.212Z', + }, + { + id: '2', + completedAt: '2024-03-15T07:33:14.212Z', + }, + { + id: '3', + completedAt: '2024-03-15T07:33:14.212Z', + }, +]; + +const unscheduledTasks = [ + { + id: '4', + }, +]; + +const todayOrPreviousTasks = [ + { + id: '5', + dueAt: '2024-03-15T07:33:14.212Z', + }, + { + id: '6', + dueAt: '2024-03-15T07:33:14.212Z', + }, +]; + +const useActivitiesMock = jest.fn( + ({ + activitiesFilters, + }: { + activitiesFilters: { completedAt: { is: 'NULL' | 'NOT_NULL' } }; + }) => { + const isCompletedFilter = activitiesFilters.completedAt.is === 'NOT_NULL'; + return { + activities: isCompletedFilter + ? completedTasks + : [...todayOrPreviousTasks, ...unscheduledTasks], + initialized: true, + }; + }, +); + +jest.mock('@/activities/hooks/useActivities', () => ({ + useActivities: jest.fn(), +})); + +(useActivities as jest.Mock).mockImplementation(useActivitiesMock); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +describe('useTasks', () => { + it("should return a user's tasks", () => { + const { result } = renderHook(() => useTasks({ targetableObjects: [] }), { + wrapper: Wrapper, + }); + + expect(result.current).toEqual({ + todayOrPreviousTasks, + upcomingTasks: [], + unscheduledTasks, + completedTasks, + initialized: true, + }); + }); +}); 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 b4d7b0179f00..0adbe01f18b3 100644 --- a/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts @@ -1,7 +1,7 @@ import { useEffect, useMemo } from 'react'; import { isNonEmptyArray } from '@sniptt/guards'; import { DateTime } from 'luxon'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { useActivities } from '@/activities/hooks/useActivities'; import { currentCompletedTaskQueryVariablesState } from '@/activities/tasks/states/currentCompletedTaskQueryVariablesState'; @@ -23,10 +23,12 @@ export const useTasks = ({ targetableObjects, filterDropdownId, }: UseTasksProps) => { - const { selectedFilter } = useFilterDropdown({ + const { selectedFilterState } = useFilterDropdown({ filterDropdownId, }); + const selectedFilter = useRecoilValue(selectedFilterState); + const assigneeIdFilter = useMemo( () => selectedFilter diff --git a/packages/twenty-front/src/modules/activities/tasks/states/currentUserTaskCountState.ts b/packages/twenty-front/src/modules/activities/tasks/states/currentUserTaskCountState.ts index 4c7653ddfe67..aa0aa58e5b7a 100644 --- a/packages/twenty-front/src/modules/activities/tasks/states/currentUserTaskCountState.ts +++ b/packages/twenty-front/src/modules/activities/tasks/states/currentUserTaskCountState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const currentUserDueTaskCountState = atom({ - default: 0, +export const currentUserDueTaskCountState = createState({ + defaultValue: 0, key: 'currentUserDueTaskCountState', }); diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx index 3574022c4b69..5ff634826d4f 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineActivity.tsx @@ -2,11 +2,11 @@ import { Tooltip } from 'react-tooltip'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { IconCheckbox, IconNotes } from 'twenty-ui'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; -import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityFirstLevelFamilySelector'; +import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityWithoutTargetsFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { IconCheckbox, IconNotes } from '@/ui/display/icon'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { Avatar } from '@/users/components/Avatar'; import { diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx index b8542cd19fee..21c0c5713014 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineCreateButtonGroup.tsx @@ -1,13 +1,10 @@ import { useSetRecoilState } from 'recoil'; -import { Button, ButtonGroup } from 'tsup.ui.index'; +import { IconCheckbox, IconNotes, IconPaperclip } from 'twenty-ui'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { - IconCheckbox, - IconNotes, - IconPaperclip, -} from '@/ui/display/icon/index'; +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 { useTabList } from '@/ui/layout/tab/hooks/useTabList'; @@ -16,8 +13,8 @@ export const TimelineCreateButtonGroup = ({ }: { targetableObject: ActivityTargetableObject; }) => { - const { getActiveTabIdState } = useTabList(TAB_LIST_COMPONENT_ID); - const setActiveTabId = useSetRecoilState(getActiveTabIdState()); + const { activeTabIdState } = useTabList(TAB_LIST_COMPONENT_ID); + const setActiveTabId = useSetRecoilState(activeTabIdState); const openCreateActivity = useOpenCreateActivityDrawer(); diff --git a/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx b/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx index 2f548e80a4c9..26a2b781b791 100644 --- a/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx +++ b/packages/twenty-front/src/modules/activities/timeline/components/TimelineQueryEffect.tsx @@ -7,12 +7,12 @@ import { objectShowPageTargetableObjectState } from '@/activities/timeline/state import { timelineActivitiesFammilyState } from '@/activities/timeline/states/timelineActivitiesFamilyState'; import { timelineActivitiesForGroupState } from '@/activities/timeline/states/timelineActivitiesForGroupState'; import { timelineActivitiesNetworkingState } from '@/activities/timeline/states/timelineActivitiesNetworkingState'; -import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityFirstLevelFamilySelector'; +import { timelineActivityWithoutTargetsFamilyState } from '@/activities/timeline/states/timelineActivityWithoutTargetsFamilyState'; import { Activity } from '@/activities/types/Activity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { sortObjectRecordByDateField } from '@/object-record/utils/sortObjectRecordByDateField'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const TimelineQueryEffect = ({ targetableObject, @@ -31,7 +31,7 @@ export const TimelineQueryEffect = ({ targetableObjects: [targetableObject], activitiesFilters: {}, activitiesOrderByVariables: FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY, - skip: !isNonNullable(targetableObject), + skip: !isDefined(targetableObject), }); const [timelineActivitiesNetworking, setTimelineActivitiesNetworking] = @@ -41,7 +41,7 @@ export const TimelineQueryEffect = ({ useRecoilState(timelineActivitiesForGroupState); useEffect(() => { - if (!isNonNullable(targetableObject)) { + if (!isDefined(targetableObject)) { return; } diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/__tests__/useTimelineActivities.test.tsx b/packages/twenty-front/src/modules/activities/timeline/hooks/__tests__/useTimelineActivities.test.tsx new file mode 100644 index 000000000000..59f931b041c5 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/hooks/__tests__/useTimelineActivities.test.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useTimelineActivities } from '@/activities/timeline/hooks/useTimelineActivities'; +import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + + {children} + + + +); + +// FIXME: The hook is re-rendering so many times that it's causing a maximum +// update depth exceeded error. We need to fix this before we can write a proper test. +describe('useTimelineActivities', () => { + it('works as expected', () => { + try { + renderHook( + () => + useTimelineActivities({ + targetableObject: { + id: '123', + targetObjectNameSingular: 'person', + }, + }), + { wrapper: Wrapper }, + ); + } catch (e) { + expect((e as Error).message).toMatch(/^Maximum update depth exceeded/); + } + }); +}); diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts deleted file mode 100644 index 19539090e062..000000000000 --- a/packages/twenty-front/src/modules/activities/timeline/hooks/useInjectIntoTimelineActivitiesQueries.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useInjectIntoActivitiesQueries } from '@/activities/hooks/useInjectIntoActivitiesQueries'; -import { Activity } from '@/activities/types/Activity'; -import { ActivityTarget } from '@/activities/types/ActivityTarget'; -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; - -export const useInjectIntoTimelineActivitiesQueries = () => { - const { injectActivitiesQueries } = useInjectIntoActivitiesQueries(); - - const injectIntoTimelineActivitiesQueries = ({ - activityToInject, - activityTargetsToInject, - timelineTargetableObject, - }: { - activityToInject: Activity; - activityTargetsToInject: ActivityTarget[]; - timelineTargetableObject: ActivityTargetableObject; - }) => { - injectActivitiesQueries({ - activitiesFilters: {}, - activitiesOrderByVariables: { - createdAt: 'DescNullsFirst', - }, - activityTargetsToInject, - activityToInject, - targetableObjects: [timelineTargetableObject], - }); - }; - - return { - injectIntoTimelineActivitiesQueries, - }; -}; diff --git a/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts b/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts index 8498ed4dac91..f114b3f8d90a 100644 --- a/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts +++ b/packages/twenty-front/src/modules/activities/timeline/hooks/useTimelineActivities.ts @@ -2,32 +2,28 @@ import { useEffect, useState } from 'react'; import { isNonEmptyArray, isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback, useRecoilState } from 'recoil'; -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; import { useActivityTargetsForTargetableObject } from '@/activities/hooks/useActivityTargetsForTargetableObject'; import { objectShowPageTargetableObjectState } from '@/activities/timeline/states/objectShowPageTargetableObjectIdState'; import { makeTimelineActivitiesQueryVariables } from '@/activities/timeline/utils/makeTimelineActivitiesQueryVariables'; import { Activity } from '@/activities/types/Activity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { sortByAscString } from '~/utils/array/sortByAscString'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useTimelineActivities = ({ targetableObject, }: { targetableObject: ActivityTargetableObject; }) => { - const { makeActivityWithoutConnection } = useActivityConnectionUtils(); - const [, setObjectShowPageTargetableObject] = useRecoilState( objectShowPageTargetableObjectState, ); useEffect(() => { - if (isNonNullable(targetableObject)) { + if (isDefined(targetableObject)) { setObjectShowPageTargetableObject(targetableObject); } }, [targetableObject, setObjectShowPageTargetableObject]); @@ -60,7 +56,7 @@ export const useTimelineActivities = ({ }, ); - const { records: activitiesWithConnection, loading: loadingActivities } = + const { records: activities, loading: loadingActivities } = useFindManyRecords({ skip: loadingActivityTargets || !isNonEmptyArray(activityTargets), objectNameSingular: CoreObjectNameSingular.Activity, @@ -68,15 +64,11 @@ export const useTimelineActivities = ({ orderBy: timelineActivitiesQueryVariables.orderBy, onCompleted: useRecoilCallback( ({ set }) => - (data) => { + (activities) => { if (!initialized) { setInitialized(true); } - const activities = getRecordsFromRecordConnection({ - recordConnection: data, - }); - for (const activity of activities) { set(recordStoreFamilyState(activity.id), activity); } @@ -97,11 +89,6 @@ export const useTimelineActivities = ({ const loading = loadingActivities || loadingActivityTargets; - const activities = activitiesWithConnection - ?.map(makeActivityWithoutConnection as any) - .map(({ activity }: any) => activity as any) - .filter(isNonNullable); - return { activities, loading, diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts index de045ed03963..f2acc7a3312f 100644 --- a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts +++ b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesFamilyState.ts @@ -1,11 +1,10 @@ -import { atomFamily } from 'recoil'; - import { Activity } from '@/activities/types/Activity'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const timelineActivitiesFammilyState = atomFamily< +export const timelineActivitiesFammilyState = createFamilyState< Activity | null, string >({ key: 'timelineActivitiesFammilyState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesForGroupState.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesForGroupState.ts index ddbea64f2135..450dc5bc7ae5 100644 --- a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesForGroupState.ts +++ b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesForGroupState.ts @@ -1,10 +1,10 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { ActivityForActivityGroup } from '@/activities/timeline/utils/groupActivitiesByMonth'; -export const timelineActivitiesForGroupState = atom( - { - key: 'timelineActivitiesForGroupState', - default: [], - }, -); +export const timelineActivitiesForGroupState = createState< + ActivityForActivityGroup[] +>({ + key: 'timelineActivitiesForGroupState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesNetworkingState.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesNetworkingState.ts index e3ca7a837680..24b4ecd6ec1a 100644 --- a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesNetworkingState.ts +++ b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivitiesNetworkingState.ts @@ -1,11 +1,11 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const timelineActivitiesNetworkingState = atom<{ +export const timelineActivitiesNetworkingState = createState<{ initialized: boolean; noActivities: boolean; }>({ key: 'timelineActivitiesNetworkingState', - default: { + defaultValue: { initialized: false, noActivities: false, }, diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivityFirstLevelFamilySelector.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivityFirstLevelFamilySelector.ts deleted file mode 100644 index 767bb5e8f2b9..000000000000 --- a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivityFirstLevelFamilySelector.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { atomFamily } from 'recoil'; - -import { Activity } from '@/activities/types/Activity'; - -export const timelineActivityWithoutTargetsFamilyState = atomFamily< - Pick | null, - string ->({ - key: 'timelineActivityFirstLevelFamilySelector', - default: null, -}); diff --git a/packages/twenty-front/src/modules/activities/timeline/states/timelineActivityWithoutTargetsFamilyState.ts b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivityWithoutTargetsFamilyState.ts new file mode 100644 index 000000000000..ac8c0f208aac --- /dev/null +++ b/packages/twenty-front/src/modules/activities/timeline/states/timelineActivityWithoutTargetsFamilyState.ts @@ -0,0 +1,10 @@ +import { Activity } from '@/activities/types/Activity'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; + +export const timelineActivityWithoutTargetsFamilyState = createFamilyState< + Pick | null, + string +>({ + key: 'timelineActivityWithoutTargetsFamilyState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/activities/timeline/utils/groupActivitiesByMonth.ts b/packages/twenty-front/src/modules/activities/timeline/utils/groupActivitiesByMonth.ts index c93550dc03ad..44bf5b9e4005 100644 --- a/packages/twenty-front/src/modules/activities/timeline/utils/groupActivitiesByMonth.ts +++ b/packages/twenty-front/src/modules/activities/timeline/utils/groupActivitiesByMonth.ts @@ -1,4 +1,5 @@ import { Activity } from '@/activities/types/Activity'; +import { isDefined } from '~/utils/isDefined'; export type ActivityForActivityGroup = Pick; @@ -21,7 +22,7 @@ export const groupActivitiesByMonth = ( const matchingGroup = acitivityGroups.find( (x) => x.year === year && x.month === month, ); - if (matchingGroup) { + if (isDefined(matchingGroup)) { matchingGroup.items.push(activity); } else { acitivityGroups.push({ diff --git a/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts b/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts index ad119af39b1f..382ce817eacc 100644 --- a/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts +++ b/packages/twenty-front/src/modules/activities/types/ActivityTargetObject.ts @@ -6,5 +6,4 @@ export type ActivityTargetWithTargetRecord = { targetObjectMetadataItem: ObjectMetadataItem; activityTarget: ActivityTarget; targetObject: ObjectRecord; - targetObjectNameSingular: string; }; diff --git a/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts b/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts index 22d25858f1fc..72b17fb37a02 100644 --- a/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts +++ b/packages/twenty-front/src/modules/activities/types/ActivityTargetableEntity.ts @@ -1,5 +1,4 @@ export type ActivityTargetableObject = { id: string; targetObjectNameSingular: string; - relatedTargetableObjects?: ActivityTargetableObject[]; }; diff --git a/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts b/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts deleted file mode 100644 index 489364a1e120..000000000000 --- a/packages/twenty-front/src/modules/activities/utils/__tests__/getTargetableEntitiesWithParents.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '@/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects'; - -describe('getTargetableEntitiesWithParents', () => { - it('should return the correct value', () => { - const entities: ActivityTargetableObject[] = [ - { - id: '1', - targetObjectNameSingular: 'person', - relatedTargetableObjects: [ - { - id: '2', - targetObjectNameSingular: 'company', - }, - ], - }, - { - id: '4', - targetObjectNameSingular: 'person', - }, - { - id: '3', - targetObjectNameSingular: 'car', - relatedTargetableObjects: [ - { - id: '6', - targetObjectNameSingular: 'person', - }, - { - id: '5', - targetObjectNameSingular: 'company', - }, - ], - }, - ]; - - const res = - flattenTargetableObjectsAndTheirRelatedTargetableObjects(entities); - - expect(res).toHaveLength(6); - expect(res[0].id).toBe('1'); - expect(res[1].id).toBe('2'); - expect(res[2].id).toBe('4'); - expect(res[3].id).toBe('3'); - expect(res[4].id).toBe('6'); - expect(res[5].id).toBe('5'); - }); -}); diff --git a/packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts b/packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts deleted file mode 100644 index 1f30e422af57..000000000000 --- a/packages/twenty-front/src/modules/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ActivityTargetableObject } from '../types/ActivityTargetableEntity'; - -export const flattenTargetableObjectsAndTheirRelatedTargetableObjects = ( - targetableObjectsWithRelatedTargetableObjects: ActivityTargetableObject[], -): ActivityTargetableObject[] => { - const flattenedTargetableObjects: ActivityTargetableObject[] = []; - - for (const targetableObject of targetableObjectsWithRelatedTargetableObjects ?? - []) { - flattenedTargetableObjects.push(targetableObject); - - if (targetableObject.relatedTargetableObjects) { - for (const relatedEntity of targetableObject.relatedTargetableObjects ?? - []) { - flattenedTargetableObjects.push(relatedEntity); - } - } - } - - return flattenedTargetableObjects; -}; diff --git a/packages/twenty-front/src/modules/activities/utils/generateActivityTargetMorphFieldKeys.ts b/packages/twenty-front/src/modules/activities/utils/generateActivityTargetMorphFieldKeys.ts new file mode 100644 index 000000000000..b5994a93e700 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/utils/generateActivityTargetMorphFieldKeys.ts @@ -0,0 +1,31 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const generateActivityTargetMorphFieldKeys = ( + objectMetadataItems: ObjectMetadataItem[], +) => { + const targetableObjects = Object.fromEntries( + objectMetadataItems + .filter( + (objectMetadataItem) => + objectMetadataItem.isActive && !objectMetadataItem.isSystem, + ) + .map((objectMetadataItem) => [objectMetadataItem.nameSingular, true]), + ); + + const targetableObjectIds = Object.fromEntries( + objectMetadataItems + .filter( + (objectMetadataItem) => + objectMetadataItem.isActive && !objectMetadataItem.isSystem, + ) + .map((objectMetadataItem) => [ + `${objectMetadataItem.nameSingular}Id`, + true, + ]), + ); + + return { + ...targetableObjects, + ...targetableObjectIds, + }; +}; diff --git a/packages/twenty-front/src/modules/activities/utils/getActivitySummary.ts b/packages/twenty-front/src/modules/activities/utils/getActivitySummary.ts index 88b27c9008eb..157cf0e45d57 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivitySummary.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivitySummary.ts @@ -1,4 +1,4 @@ -import { isArray } from '@sniptt/guards'; +import { isArray, isNonEmptyString } from '@sniptt/guards'; export const getActivitySummary = (activityBody: string) => { const noteBody = activityBody ? JSON.parse(activityBody) : []; @@ -13,7 +13,7 @@ export const getActivitySummary = (activityBody: string) => { return ''; } - if (firstNoteBlockContent.text) { + if (isNonEmptyString(firstNoteBlockContent.text)) { return noteBody[0].content.text; } diff --git a/packages/twenty-front/src/modules/activities/utils/getTargetObjectFilterFieldName.ts b/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectFieldIdName.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/utils/getTargetObjectFilterFieldName.ts rename to packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectFieldIdName.ts diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectFieldName.ts b/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectFieldName.ts new file mode 100644 index 000000000000..a4081e2a1085 --- /dev/null +++ b/packages/twenty-front/src/modules/activities/utils/getActivityTargetObjectFieldName.ts @@ -0,0 +1,7 @@ +export const getActivityTargetObjectFieldName = ({ + nameSingular, +}: { + nameSingular: string; +}) => { + return `${nameSingular}`; +}; diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts b/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts index c6a53c39eec4..5553632b1c7d 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivityTargetsFilter.ts @@ -1,5 +1,5 @@ import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; export const getActivityTargetsFilter = ({ targetableObjects, diff --git a/packages/twenty-front/src/modules/activities/utils/getActivityTargetsToCreateFromTargetableObjects.ts b/packages/twenty-front/src/modules/activities/utils/getActivityTargetsToCreateFromTargetableObjects.ts index 20b8d009c256..dd20d678d8d9 100644 --- a/packages/twenty-front/src/modules/activities/utils/getActivityTargetsToCreateFromTargetableObjects.ts +++ b/packages/twenty-front/src/modules/activities/utils/getActivityTargetsToCreateFromTargetableObjects.ts @@ -1,48 +1,41 @@ import { v4 } from 'uuid'; +import { Activity } from '@/activities/types/Activity'; import { ActivityTarget } from '@/activities/types/ActivityTarget'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { flattenTargetableObjectsAndTheirRelatedTargetableObjects } from '@/activities/utils/flattenTargetableObjectsAndTheirRelatedTargetableObjects'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getTargetObjectFilterFieldName'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; export const makeActivityTargetsToCreateFromTargetableObjects = ({ targetableObjects, - activityId, + activity, targetObjectRecords, }: { targetableObjects: ActivityTargetableObject[]; - activityId: string; + activity: Activity; targetObjectRecords: ObjectRecord[]; }): Partial[] => { - const activityTargetableObjects = targetableObjects - ? flattenTargetableObjectsAndTheirRelatedTargetableObjects( - targetableObjects, - ) - : []; + const activityTargetsToCreate = targetableObjects.map((targetableObject) => { + const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ + nameSingular: targetableObject.targetObjectNameSingular, + }); - const activityTargetsToCreate = activityTargetableObjects.map( - (targetableObject) => { - const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ - nameSingular: targetableObject.targetObjectNameSingular, - }); + const relatedObjectRecord = targetObjectRecords.find( + (record) => record.id === targetableObject.id, + ); - const relatedObjectRecord = targetObjectRecords.find( - (record) => record.id === targetableObject.id, - ); + const activityTarget = { + [targetableObject.targetObjectNameSingular]: relatedObjectRecord, + [targetableObjectFieldIdName]: targetableObject.id, + activity, + activityId: activity.id, + id: v4(), + updatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + } as Partial; - const activityTarget = { - [targetableObject.targetObjectNameSingular]: relatedObjectRecord, - [targetableObjectFieldIdName]: targetableObject.id, - activityId, - id: v4(), - updatedAt: new Date().toISOString(), - createdAt: new Date().toISOString(), - } as Partial; - - return activityTarget; - }, - ); + return activityTarget; + }); return activityTargetsToCreate; }; diff --git a/packages/twenty-front/src/modules/analytics/graphql/queries/createEvent.ts b/packages/twenty-front/src/modules/analytics/graphql/queries/createEvent.ts deleted file mode 100644 index 6183b9107325..000000000000 --- a/packages/twenty-front/src/modules/analytics/graphql/queries/createEvent.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { gql } from '@apollo/client'; - -export const CREATE_EVENT = gql` - mutation CreateEvent($type: String!, $data: JSON!) { - createEvent(type: $type, data: $data) { - success - } - } -`; diff --git a/packages/twenty-front/src/modules/analytics/graphql/queries/track.ts b/packages/twenty-front/src/modules/analytics/graphql/queries/track.ts new file mode 100644 index 000000000000..3aa7bba0fcf4 --- /dev/null +++ b/packages/twenty-front/src/modules/analytics/graphql/queries/track.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const TRACK = gql` + mutation Track($type: String!, $data: JSON!) { + track(type: $type, data: $data) { + success + } + } +`; diff --git a/packages/twenty-front/src/modules/analytics/hooks/__tests__/useEventTracker.test.tsx b/packages/twenty-front/src/modules/analytics/hooks/__tests__/useEventTracker.test.tsx index fe22734e8229..9b60f1ffa646 100644 --- a/packages/twenty-front/src/modules/analytics/hooks/__tests__/useEventTracker.test.tsx +++ b/packages/twenty-front/src/modules/analytics/hooks/__tests__/useEventTracker.test.tsx @@ -7,16 +7,12 @@ import { RecoilRoot } from 'recoil'; import { useEventTracker } from '../useEventTracker'; -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: () => () => '\n', -})); - const mocks: MockedResponse[] = [ { request: { query: gql` - mutation CreateEvent($type: String!, $data: JSON!) { - createEvent(type: $type, data: $data) { + mutation Track($type: String!, $data: JSON!) { + track(type: $type, data: $data) { success } } @@ -28,7 +24,7 @@ const mocks: MockedResponse[] = [ }, result: jest.fn(() => ({ data: { - createEvent: { + track: { success: true, }, }, diff --git a/packages/twenty-front/src/modules/analytics/hooks/__tests__/useTrackEvent.test.tsx b/packages/twenty-front/src/modules/analytics/hooks/__tests__/useTrackEvent.test.tsx index 69eeecdf1ca8..f82b7323a658 100644 --- a/packages/twenty-front/src/modules/analytics/hooks/__tests__/useTrackEvent.test.tsx +++ b/packages/twenty-front/src/modules/analytics/hooks/__tests__/useTrackEvent.test.tsx @@ -4,10 +4,10 @@ import { RecoilRoot } from 'recoil'; import { useTrackEvent } from '../useTrackEvent'; -const mockCreateEventMutation = jest.fn(); +const mockTrackMutation = jest.fn(); jest.mock('~/generated/graphql', () => ({ - useCreateEventMutation: () => [mockCreateEventMutation], + useTrackMutation: () => [mockTrackMutation], })); describe('useTrackEvent', () => { @@ -17,8 +17,8 @@ describe('useTrackEvent', () => { renderHook(() => useTrackEvent(eventType, eventData), { wrapper: RecoilRoot, }); - expect(mockCreateEventMutation).toHaveBeenCalledTimes(1); - expect(mockCreateEventMutation).toHaveBeenCalledWith({ + expect(mockTrackMutation).toHaveBeenCalledTimes(1); + expect(mockTrackMutation).toHaveBeenCalledWith({ variables: { type: eventType, data: eventData }, }); }); diff --git a/packages/twenty-front/src/modules/analytics/hooks/useEventTracker.ts b/packages/twenty-front/src/modules/analytics/hooks/useEventTracker.ts index 6cb0fcf4d10b..88d1d656740b 100644 --- a/packages/twenty-front/src/modules/analytics/hooks/useEventTracker.ts +++ b/packages/twenty-front/src/modules/analytics/hooks/useEventTracker.ts @@ -1,8 +1,8 @@ import { useCallback } from 'react'; -import { useRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; import { telemetryState } from '@/client-config/states/telemetryState'; -import { useCreateEventMutation } from '~/generated/graphql'; +import { useTrackMutation } from '~/generated/graphql'; interface EventLocation { pathname: string; @@ -13,8 +13,8 @@ export interface EventData { } export const useEventTracker = () => { - const [telemetry] = useRecoilState(telemetryState); - const [createEventMutation] = useCreateEventMutation(); + const telemetry = useRecoilValue(telemetryState); + const [createEventMutation] = useTrackMutation(); return useCallback( (eventType: string, eventData: EventData) => { diff --git a/packages/twenty-front/src/modules/apollo/components/ApolloProvider.tsx b/packages/twenty-front/src/modules/apollo/components/ApolloProvider.tsx index 21a5015285de..c0d771c61f25 100644 --- a/packages/twenty-front/src/modules/apollo/components/ApolloProvider.tsx +++ b/packages/twenty-front/src/modules/apollo/components/ApolloProvider.tsx @@ -3,7 +3,9 @@ import { ApolloProvider as ApolloProviderBase } from '@apollo/client'; import { useApolloFactory } from '@/apollo/hooks/useApolloFactory'; export const ApolloProvider = ({ children }: React.PropsWithChildren) => { - const apolloClient = useApolloFactory(); + const apolloClient = useApolloFactory({ + connectToDevTools: true, + }); // This will attach the right apollo client to Apollo Dev Tools window.__APOLLO_CLIENT__ = apolloClient; diff --git a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx index bea66cc15c5f..59f99306a524 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx +++ b/packages/twenty-front/src/modules/apollo/hooks/__tests__/useApolloFactory.test.tsx @@ -22,7 +22,7 @@ jest.mock('react-router-dom', () => { const Wrapper = ({ children }: { children: React.ReactNode }) => ( {children} @@ -44,7 +44,7 @@ describe('useApolloFactory', () => { expect(res).toHaveProperty('query'); }); - it('should navigate to /sign-in on unauthenticated error', async () => { + it('should navigate to /welcome on unauthenticated error', async () => { const errors = [ { extensions: { @@ -77,8 +77,8 @@ describe('useApolloFactory', () => { await act(async () => { await result.current.factory.mutate({ mutation: gql` - mutation CreateEvent($type: String!, $data: JSON!) { - createEvent(type: $type, data: $data) { + mutation Track($type: String!, $data: JSON!) { + track(type: $type, data: $data) { success } } @@ -90,7 +90,7 @@ describe('useApolloFactory', () => { expect((error as ApolloError).message).toBe('Error message not found.'); expect(mockNavigate).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalledWith('/sign-in'); + expect(mockNavigate).toHaveBeenCalledWith('/welcome'); } }); }); diff --git a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts index cd2b62704f62..768142302afb 100644 --- a/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts +++ b/packages/twenty-front/src/modules/apollo/hooks/useApolloFactory.ts @@ -1,30 +1,41 @@ import { useMemo, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { InMemoryCache, NormalizedCacheObject } from '@apollo/client'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { previousUrlState } from '@/auth/states/previousUrlState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { AppPath } from '@/types/AppPath'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; import { useUpdateEffect } from '~/hooks/useUpdateEffect'; +import { isDefined } from '~/utils/isDefined'; -import { ApolloFactory } from '../services/apollo.factory'; +import { ApolloFactory, Options } from '../services/apollo.factory'; -export const useApolloFactory = () => { +export const useApolloFactory = (options: Partial> = {}) => { // eslint-disable-next-line @nx/workspace-no-state-useref const apolloRef = useRef | null>(null); + const currentWorkspace = useRecoilValue(currentWorkspaceState); const [isDebugMode] = useRecoilState(isDebugModeState); const navigate = useNavigate(); const isMatchingLocation = useIsMatchingLocation(); const [tokenPair, setTokenPair] = useRecoilState(tokenPairState); + const [, setPreviousUrl] = useRecoilState(previousUrlState); + const location = useLocation(); const apolloClient = useMemo(() => { apolloRef.current = new ApolloFactory({ uri: `${REACT_APP_SERVER_BASE_URL}/graphql`, cache: new InMemoryCache(), + headers: { + ...(currentWorkspace?.currentCacheVersion && { + 'X-Schema-Version': currentWorkspace.currentCacheVersion, + }), + }, defaultOptions: { query: { fetchPolicy: 'cache-first', @@ -40,24 +51,31 @@ export const useApolloFactory = () => { setTokenPair(null); if ( !isMatchingLocation(AppPath.Verify) && - !isMatchingLocation(AppPath.SignIn) && - !isMatchingLocation(AppPath.SignUp) && + !isMatchingLocation(AppPath.SignInUp) && !isMatchingLocation(AppPath.Invite) && !isMatchingLocation(AppPath.ResetPassword) ) { - navigate(AppPath.SignIn); + setPreviousUrl(`${location.pathname}${location.search}`); + navigate(AppPath.SignInUp); } }, extraLinks: [], isDebugMode, + // Override options + ...options, }); return apolloRef.current.getClient(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [setTokenPair, isDebugMode]); + }, [ + setTokenPair, + isDebugMode, + currentWorkspace?.currentCacheVersion, + setPreviousUrl, + ]); useUpdateEffect(() => { - if (apolloRef.current) { + if (isDefined(apolloRef.current)) { apolloRef.current.updateTokenPair(tokenPair); } }, [tokenPair]); diff --git a/packages/twenty-front/src/modules/apollo/hooks/useCachedRootQuery.ts b/packages/twenty-front/src/modules/apollo/hooks/useCachedRootQuery.ts deleted file mode 100644 index b16bb88947d5..000000000000 --- a/packages/twenty-front/src/modules/apollo/hooks/useCachedRootQuery.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useApolloClient } from '@apollo/client/react/hooks/useApolloClient'; -import gql from 'graphql-tag'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { QueryMethodName } from '@/object-metadata/types/QueryMethodName'; - -export const useCachedRootQuery = ({ - objectMetadataItem, - queryMethodName, -}: { - objectMetadataItem: ObjectMetadataItem | undefined; - queryMethodName: QueryMethodName; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - const apolloClient = useApolloClient(); - - if (!objectMetadataItem) { - return { cachedRootQuery: null }; - } - - const buildRecordFieldsFragment = () => { - return objectMetadataItem.fields - .filter((field) => field.type !== 'RELATION') - .map((field) => mapFieldMetadataToGraphQLQuery({ field })) - .join(' \n'); - }; - - const cacheReadFragment = gql` - fragment RootQuery on Query { - ${ - QueryMethodName.FindMany === queryMethodName - ? objectMetadataItem.namePlural - : objectMetadataItem.nameSingular - } { - ${QueryMethodName.FindMany === queryMethodName ? 'edges { node { ' : ''} - ${buildRecordFieldsFragment()} - ${QueryMethodName.FindMany === queryMethodName ? '}}' : ''} - - } - } - `; - - const cachedRootQuery = apolloClient.readFragment({ - id: 'ROOT_QUERY', - fragment: cacheReadFragment, - }); - - return { cachedRootQuery }; -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts deleted file mode 100644 index 92186f3df316..000000000000 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isCachedObjectRecordConnection.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { StoreValue } from '@apollo/client'; -import { z } from 'zod'; - -import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; -import { capitalize } from '~/utils/string/capitalize'; - -export const isCachedObjectRecordConnection = ( - objectNameSingular: string, - storeValue: StoreValue, -): storeValue is CachedObjectRecordConnection => { - const objectConnectionTypeName = `${capitalize( - objectNameSingular, - )}Connection`; - const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; - const cachedObjectConnectionSchema = z.object({ - __typename: z.literal(objectConnectionTypeName), - edges: z.array( - z.object({ - __typename: z.literal(objectEdgeTypeName), - node: z.object({ - __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), - }), - }), - ), - }); - const cachedConnectionValidation = - cachedObjectConnectionSchema.safeParse(storeValue); - - return cachedConnectionValidation.success; -}; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts index 536563efd0f7..10d4514af854 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/sortCachedObjectEdges.ts @@ -4,7 +4,7 @@ import { ReadFieldFunction } from '@apollo/client/cache/core/types/common'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { OrderBy } from '@/object-metadata/types/OrderBy'; import { OrderByField } from '@/object-metadata/types/OrderByField'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { sortAsc, sortDesc, sortNullsFirst, sortNullsLast } from '~/utils/sort'; export const sortCachedObjectEdges = ({ @@ -31,7 +31,7 @@ export const sortCachedObjectEdges = ({ orderByFieldName, recordFromCache, ) ?? null; - const isSubFieldFilter = isNonNullable(fieldValue) && !!orderBySubFieldName; + const isSubFieldFilter = isDefined(fieldValue) && !!orderBySubFieldName; if (!isSubFieldFilter) return fieldValue as string | number | null; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts index 188d430834f9..67ba4f8c69e9 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect.ts @@ -1,8 +1,8 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const triggerAttachRelationOptimisticEffect = ({ @@ -32,8 +32,8 @@ export const triggerAttachRelationOptimisticEffect = ({ id: targetRecordCacheId, fields: { [fieldNameOnTargetRecord]: (targetRecordFieldValue, { toReference }) => { - const fieldValueIsCachedObjectRecordConnection = - isCachedObjectRecordConnection( + const fieldValueisObjectRecordConnectionWithRefs = + isObjectRecordConnectionWithRefs( sourceObjectNameSingular, targetRecordFieldValue, ); @@ -43,11 +43,11 @@ export const triggerAttachRelationOptimisticEffect = ({ __typename: sourceRecordTypeName, }); - if (!isNonNullable(sourceRecordReference)) { + if (!isDefined(sourceRecordReference)) { return targetRecordFieldValue; } - if (fieldValueIsCachedObjectRecordConnection) { + if (fieldValueisObjectRecordConnectionWithRefs) { const nextEdges: CachedObjectRecordEdge[] = [ ...targetRecordFieldValue.edges, { diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts index ec4061eef9e1..84d7aab6bf0e 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect.ts @@ -1,11 +1,12 @@ import { ApolloCache, StoreObject } from '@apollo/client'; +import { isNonEmptyString } from '@sniptt/guards'; -import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename'; +import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; /* TODO: for now new records are added to all cached record lists, no matter what the variables (filters, orderBy, etc.) are. @@ -23,10 +24,6 @@ export const triggerCreateRecordsOptimisticEffect = ({ recordsToCreate: CachedObjectRecord[]; objectMetadataItems: ObjectMetadataItem[]; }) => { - const objectEdgeTypeName = getEdgeTypename({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - recordsToCreate.forEach((record) => triggerUpdateRelationsOptimisticEffect({ cache, @@ -48,7 +45,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ toReference, }, ) => { - const shouldSkip = !isCachedObjectRecordConnection( + const shouldSkip = !isObjectRecordConnectionWithRefs( objectMetadataItem.nameSingular, rootQueryCachedResponse, ); @@ -76,7 +73,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ const hasAddedRecords = recordsToCreate .map((recordToCreate) => { - if (recordToCreate.id) { + if (isNonEmptyString(recordToCreate.id)) { const recordToCreateReference = toReference(recordToCreate); if (!recordToCreateReference) { @@ -96,7 +93,7 @@ export const triggerCreateRecordsOptimisticEffect = ({ if (recordToCreateReference && !recordAlreadyInCache) { nextRootQueryCachedRecordEdges.unshift({ - __typename: objectEdgeTypeName, + __typename: getEdgeTypename(objectMetadataItem.nameSingular), node: recordToCreateReference, cursor: '', }); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts index 6ffff14c72d4..7c381ac866fb 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect.ts @@ -1,12 +1,12 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; +import { isDefined } from '~/utils/isDefined'; import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; export const triggerDeleteRecordsOptimisticEffect = ({ @@ -27,7 +27,7 @@ export const triggerDeleteRecordsOptimisticEffect = ({ { DELETE, readField, storeFieldName }, ) => { const rootQueryCachedResponseIsNotACachedObjectRecordConnection = - !isCachedObjectRecordConnection( + !isObjectRecordConnectionWithRefs( objectMetadataItem.nameSingular, rootQueryCachedResponse, ); @@ -68,7 +68,7 @@ export const triggerDeleteRecordsOptimisticEffect = ({ // TODO: same as in update, should we trigger DELETE ? if ( - isNonNullable(rootQueryVariables?.first) && + isDefined(rootQueryVariables?.first) && cachedEdges?.length === rootQueryVariables.first ) { return DELETE; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts index 3d0080526182..d32185298973 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect.ts @@ -1,6 +1,6 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; +import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; import { capitalize } from '~/utils/string/capitalize'; export const triggerDetachRelationOptimisticEffect = ({ @@ -32,7 +32,7 @@ export const triggerDetachRelationOptimisticEffect = ({ targetRecordFieldValue, { isReference, readField }, ) => { - const isRecordConnection = isCachedObjectRecordConnection( + const isRecordConnection = isObjectRecordConnectionWithRefs( sourceObjectNameSingular, targetRecordFieldValue, ); diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts index 92890351c890..50c1faf7909c 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect.ts @@ -1,6 +1,5 @@ import { ApolloCache, StoreObject } from '@apollo/client'; -import { isCachedObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isCachedObjectRecordConnection'; import { sortCachedObjectEdges } from '@/apollo/optimistic-effect/utils/sortCachedObjectEdges'; import { triggerUpdateRelationsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect'; import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; @@ -8,8 +7,9 @@ import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; import { CachedObjectRecordQueryVariables } from '@/apollo/types/CachedObjectRecordQueryVariables'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename'; +import { isObjectRecordConnectionWithRefs } from '@/object-record/cache/utils/isObjectRecordConnectionWithRefs'; import { isRecordMatchingFilter } from '@/object-record/record-filter/utils/isRecordMatchingFilter'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { parseApolloStoreFieldName } from '~/utils/parseApolloStoreFieldName'; // TODO: add extensive unit tests for this function @@ -27,10 +27,6 @@ export const triggerUpdateRecordOptimisticEffect = ({ updatedRecord: CachedObjectRecord; objectMetadataItems: ObjectMetadataItem[]; }) => { - const objectEdgeTypeName = getEdgeTypename({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - triggerUpdateRelationsOptimisticEffect({ cache, sourceObjectMetadataItem: objectMetadataItem, @@ -45,7 +41,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ rootQueryCachedResponse, { DELETE, readField, storeFieldName, toReference }, ) => { - const shouldSkip = !isCachedObjectRecordConnection( + const shouldSkip = !isObjectRecordConnectionWithRefs( objectMetadataItem.nameSingular, rootQueryCachedResponse, ); @@ -71,7 +67,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ const rootQueryOrderBy = rootQueryVariables?.orderBy; const rootQueryLimit = rootQueryVariables?.first; - const shouldTryToMatchFilter = isNonNullable(rootQueryFilter); + const shouldTryToMatchFilter = isDefined(rootQueryFilter); if (shouldTryToMatchFilter) { const updatedRecordMatchesThisRootQueryFilter = @@ -101,9 +97,9 @@ export const triggerUpdateRecordOptimisticEffect = ({ if (updatedRecordShouldBeAddedToRootQueryEdges) { const updatedRecordNodeReference = toReference(updatedRecord); - if (isNonNullable(updatedRecordNodeReference)) { + if (isDefined(updatedRecordNodeReference)) { rootQueryNextEdges.push({ - __typename: objectEdgeTypeName, + __typename: getEdgeTypename(objectMetadataItem.nameSingular), node: updatedRecordNodeReference, cursor: '', }); @@ -115,8 +111,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ } } - const rootQueryNextEdgesShouldBeSorted = - isNonNullable(rootQueryOrderBy); + const rootQueryNextEdgesShouldBeSorted = isDefined(rootQueryOrderBy); if ( rootQueryNextEdgesShouldBeSorted && @@ -129,7 +124,7 @@ export const triggerUpdateRecordOptimisticEffect = ({ }); } - const shouldLimitNextRootQueryEdges = isNonNullable(rootQueryLimit); + const shouldLimitNextRootQueryEdges = isDefined(rootQueryLimit); // TODO: not sure that we should trigger a DELETE here, as it will trigger a network request // Is it the responsibility of this optimistic effect function to delete a root query that will trigger a network request ? diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts index aa9eac813ad9..d8deb5119d3c 100644 --- a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts +++ b/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/triggerUpdateRelationsOptimisticEffect.ts @@ -1,7 +1,6 @@ import { ApolloCache } from '@apollo/client'; import { getRelationDefinition } from '@/apollo/optimistic-effect/utils/getRelationDefinition'; -import { isObjectRecordConnection } from '@/apollo/optimistic-effect/utils/isObjectRecordConnection'; import { triggerAttachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerAttachRelationOptimisticEffect'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; import { triggerDetachRelationOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDetachRelationOptimisticEffect'; @@ -9,10 +8,11 @@ import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH } from '@/apollo/types/coreObjectNamesToDeleteOnRelationDetach'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { isObjectRecordConnection } from '@/object-record/cache/utils/isObjectRecordConnection'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const triggerUpdateRelationsOptimisticEffect = ({ cache, @@ -26,128 +26,129 @@ export const triggerUpdateRelationsOptimisticEffect = ({ currentSourceRecord: CachedObjectRecord | null; updatedSourceRecord: CachedObjectRecord | null; objectMetadataItems: ObjectMetadataItem[]; -}) => - sourceObjectMetadataItem.fields.forEach((fieldMetadataItemOnSourceRecord) => { - const notARelationField = - fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.Relation; - - if (notARelationField) { - return; - } - - const fieldDoesNotExist = - isNonNullable(updatedSourceRecord) && - !(fieldMetadataItemOnSourceRecord.name in updatedSourceRecord); - - if (fieldDoesNotExist) { - return; - } - - const relationDefinition = getRelationDefinition({ - fieldMetadataItemOnSourceRecord, - objectMetadataItems, - }); - - if (!relationDefinition) { - return; - } - - const { targetObjectMetadataItem, fieldMetadataItemOnTargetRecord } = - relationDefinition; - - const currentFieldValueOnSourceRecord: - | ObjectRecordConnection - | CachedObjectRecord - | null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; - - const updatedFieldValueOnSourceRecord: - | ObjectRecordConnection - | CachedObjectRecord - | null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; - - if ( - isDeeplyEqual( - currentFieldValueOnSourceRecord, - updatedFieldValueOnSourceRecord, - ) - ) { - return; - } - - // TODO: replace this by a relation type check, if it's one to many, - // it's an object record connection (we can still check it though as a safeguard) - const currentFieldValueOnSourceRecordIsARecordConnection = - isObjectRecordConnection( - targetObjectMetadataItem.nameSingular, - currentFieldValueOnSourceRecord, - ); - - const targetRecordsToDetachFrom = - currentFieldValueOnSourceRecordIsARecordConnection - ? currentFieldValueOnSourceRecord.edges.map( - ({ node }) => node as CachedObjectRecord, - ) - : [currentFieldValueOnSourceRecord].filter(isNonNullable); - - const updatedFieldValueOnSourceRecordIsARecordConnection = - isObjectRecordConnection( - targetObjectMetadataItem.nameSingular, - updatedFieldValueOnSourceRecord, - ); - - const targetRecordsToAttachTo = - updatedFieldValueOnSourceRecordIsARecordConnection - ? updatedFieldValueOnSourceRecord.edges.map( - ({ node }) => node as CachedObjectRecord, - ) - : [updatedFieldValueOnSourceRecord].filter(isNonNullable); - - const shouldDetachSourceFromAllTargets = - isNonNullable(currentSourceRecord) && - targetRecordsToDetachFrom.length > 0; - - if (shouldDetachSourceFromAllTargets) { - // TODO: see if we can de-hardcode this, put cascade delete in relation metadata item - // Instead of hardcoding it here - const shouldCascadeDeleteTargetRecords = - CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( - targetObjectMetadataItem.nameSingular as CoreObjectNameSingular, +}) => { + return sourceObjectMetadataItem.fields.forEach( + (fieldMetadataItemOnSourceRecord) => { + const notARelationField = + fieldMetadataItemOnSourceRecord.type !== FieldMetadataType.Relation; + + if (notARelationField) { + return; + } + + const fieldDoesNotExist = + isDefined(updatedSourceRecord) && + !(fieldMetadataItemOnSourceRecord.name in updatedSourceRecord); + + if (fieldDoesNotExist) { + return; + } + + const relationDefinition = getRelationDefinition({ + fieldMetadataItemOnSourceRecord, + objectMetadataItems, + }); + if (!relationDefinition) { + return; + } + + const { targetObjectMetadataItem, fieldMetadataItemOnTargetRecord } = + relationDefinition; + + const currentFieldValueOnSourceRecord: + | ObjectRecordConnection + | CachedObjectRecord + | null = currentSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; + + const updatedFieldValueOnSourceRecord: + | ObjectRecordConnection + | CachedObjectRecord + | null = updatedSourceRecord?.[fieldMetadataItemOnSourceRecord.name]; + + if ( + isDeeplyEqual( + currentFieldValueOnSourceRecord, + updatedFieldValueOnSourceRecord, + ) + ) { + return; + } + + // TODO: replace this by a relation type check, if it's one to many, + // it's an object record connection (we can still check it though as a safeguard) + const currentFieldValueOnSourceRecordIsARecordConnection = + isObjectRecordConnection( + targetObjectMetadataItem.nameSingular, + currentFieldValueOnSourceRecord, ); - if (shouldCascadeDeleteTargetRecords) { - triggerDeleteRecordsOptimisticEffect({ - cache, - objectMetadataItem: targetObjectMetadataItem, - recordsToDelete: targetRecordsToDetachFrom, - objectMetadataItems, - }); - } else { - targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { - triggerDetachRelationOptimisticEffect({ + const targetRecordsToDetachFrom = + currentFieldValueOnSourceRecordIsARecordConnection + ? currentFieldValueOnSourceRecord.edges.map( + ({ node }) => node as CachedObjectRecord, + ) + : [currentFieldValueOnSourceRecord].filter(isDefined); + + const updatedFieldValueOnSourceRecordIsARecordConnection = + isObjectRecordConnection( + targetObjectMetadataItem.nameSingular, + updatedFieldValueOnSourceRecord, + ); + + const targetRecordsToAttachTo = + updatedFieldValueOnSourceRecordIsARecordConnection + ? updatedFieldValueOnSourceRecord.edges.map( + ({ node }) => node as CachedObjectRecord, + ) + : [updatedFieldValueOnSourceRecord].filter(isDefined); + + const shouldDetachSourceFromAllTargets = + isDefined(currentSourceRecord) && targetRecordsToDetachFrom.length > 0; + + if (shouldDetachSourceFromAllTargets) { + // TODO: see if we can de-hardcode this, put cascade delete in relation metadata item + // Instead of hardcoding it here + const shouldCascadeDeleteTargetRecords = + CORE_OBJECT_NAMES_TO_DELETE_ON_TRIGGER_RELATION_DETACH.includes( + targetObjectMetadataItem.nameSingular as CoreObjectNameSingular, + ); + + if (shouldCascadeDeleteTargetRecords) { + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem: targetObjectMetadataItem, + recordsToDelete: targetRecordsToDetachFrom, + objectMetadataItems, + }); + } else { + targetRecordsToDetachFrom.forEach((targetRecordToDetachFrom) => { + triggerDetachRelationOptimisticEffect({ + cache, + sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, + sourceRecordId: currentSourceRecord.id, + fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name, + targetObjectNameSingular: targetObjectMetadataItem.nameSingular, + targetRecordId: targetRecordToDetachFrom.id, + }); + }); + } + } + + const shouldAttachSourceToAllTargets = + isDefined(updatedSourceRecord) && targetRecordsToAttachTo.length > 0; + + if (shouldAttachSourceToAllTargets) { + targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => + triggerAttachRelationOptimisticEffect({ cache, sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, - sourceRecordId: currentSourceRecord.id, + sourceRecordId: updatedSourceRecord.id, fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name, targetObjectNameSingular: targetObjectMetadataItem.nameSingular, - targetRecordId: targetRecordToDetachFrom.id, - }); - }); + targetRecordId: targetRecordToAttachTo.id, + }), + ); } - } - - const shouldAttachSourceToAllTargets = - updatedSourceRecord && targetRecordsToAttachTo.length; - - if (shouldAttachSourceToAllTargets) { - targetRecordsToAttachTo.forEach((targetRecordToAttachTo) => - triggerAttachRelationOptimisticEffect({ - cache, - sourceObjectNameSingular: sourceObjectMetadataItem.nameSingular, - sourceRecordId: updatedSourceRecord.id, - fieldNameOnTargetRecord: fieldMetadataItemOnTargetRecord.name, - targetObjectNameSingular: targetObjectMetadataItem.nameSingular, - targetRecordId: targetRecordToAttachTo.id, - }), - ); - } - }); + }, + ); +}; diff --git a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts index 7bd43be26a84..d0ba37512438 100644 --- a/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts +++ b/packages/twenty-front/src/modules/apollo/services/__tests__/apollo.factory.test.ts @@ -41,8 +41,8 @@ const makeRequest = async () => { await client.mutate({ mutation: gql` - mutation CreateEvent($type: String!, $data: JSON!) { - createEvent(type: $type, data: $data) { + mutation Track($type: String!, $data: JSON!) { + track(type: $type, data: $data) { success } } diff --git a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts index 23b4612f5bdc..bb7f3851e006 100644 --- a/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts +++ b/packages/twenty-front/src/modules/apollo/services/apollo.factory.ts @@ -14,7 +14,7 @@ import { createUploadLink } from 'apollo-upload-client'; import { renewToken } from '@/auth/services/AuthService'; import { AuthTokenPair } from '~/generated/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { logDebug } from '~/utils/logDebug'; import { ApolloManager } from '../types/apolloManager.interface'; @@ -60,6 +60,7 @@ export class ApolloFactory implements ApolloManager { return { headers: { ...headers, + ...options.headers, authorization: this.tokenPair?.accessToken.token ? `Bearer ${this.tokenPair?.accessToken.token}` : '', @@ -78,7 +79,7 @@ export class ApolloFactory implements ApolloManager { }); const errorLink = onError( ({ graphQLErrors, networkError, forward, operation }) => { - if (graphQLErrors) { + if (isDefined(graphQLErrors)) { onErrorCb?.(graphQLErrors); for (const graphQLError of graphQLErrors) { @@ -86,7 +87,9 @@ export class ApolloFactory implements ApolloManager { return fromPromise( renewToken(uri, this.tokenPair) .then((tokens) => { - onTokenPairChange?.(tokens); + if (isDefined(tokens)) { + onTokenPairChange?.(tokens); + } }) .catch(() => { onUnauthenticatedError?.(); @@ -99,7 +102,9 @@ export class ApolloFactory implements ApolloManager { return fromPromise( renewToken(uri, this.tokenPair) .then((tokens) => { - onTokenPairChange?.(tokens); + if (isDefined(tokens)) { + onTokenPairChange?.(tokens); + } }) .catch(() => { onUnauthenticatedError?.(); @@ -107,7 +112,7 @@ export class ApolloFactory implements ApolloManager { ).flatMap(() => forward(operation)); } default: - if (isDebugMode) { + if (isDebugMode === true) { logDebug( `[GraphQL error]: Message: ${ graphQLError.message @@ -122,8 +127,8 @@ export class ApolloFactory implements ApolloManager { } } - if (networkError) { - if (isDebugMode) { + if (isDefined(networkError)) { + if (isDebugMode === true) { logDebug(`[Network error]: ${networkError}`); } onNetworkError?.(networkError); @@ -139,7 +144,7 @@ export class ApolloFactory implements ApolloManager { isDebugMode ? logger : null, retryLink, httpLink, - ].filter(isNonNullable), + ].filter(isDefined), ); }; diff --git a/packages/twenty-front/src/modules/apollo/utils/index.ts b/packages/twenty-front/src/modules/apollo/utils/index.ts index d182601aa129..b57f427cf192 100644 --- a/packages/twenty-front/src/modules/apollo/utils/index.ts +++ b/packages/twenty-front/src/modules/apollo/utils/index.ts @@ -1,5 +1,6 @@ import { ApolloLink, gql, Operation } from '@apollo/client'; +import { isDefined } from '~/utils/isDefined'; import { logDebug } from '~/utils/logDebug'; import { logError } from '~/utils/logError'; @@ -29,7 +30,9 @@ export const loggerLink = (getSchemaName: (operation: Operation) => string) => const operationType = (operation.query.definitions[0] as any).operation; const headers = operation.getContext().headers; - const [queryName, query] = parseQuery(operation.query.loc!.source.body); + const [queryName, query] = parseQuery( + operation.query.loc?.source.body ?? '', + ); if (operationType === 'subscription') { const date = new Date().toLocaleTimeString(); @@ -64,7 +67,7 @@ export const loggerLink = (getSchemaName: (operation: Operation) => string) => getGroup(!hasError)(...titleArgs); - if (errors) { + if (isDefined(errors)) { errors.forEach((err: any) => { logDebug( `%c${err.message}`, @@ -82,10 +85,10 @@ export const loggerLink = (getSchemaName: (operation: Operation) => string) => logDebug('QUERY', query); - if (result.data) { + if (isDefined(result.data)) { logDebug('RESULT', result.data); } - if (errors) { + if (isDefined(errors)) { logDebug('ERRORS', errors); } @@ -95,7 +98,7 @@ export const loggerLink = (getSchemaName: (operation: Operation) => string) => logDebug( `${operationType} ${schemaName}::${queryName} (in ${time} ms)`, ); - if (errors) { + if (isDefined(errors)) { logError(errors); } } diff --git a/packages/twenty-front/src/modules/attachments/types/Attachment.ts b/packages/twenty-front/src/modules/attachments/types/Attachment.ts index 657ce6b480fe..21f316d8e03c 100644 --- a/packages/twenty-front/src/modules/attachments/types/Attachment.ts +++ b/packages/twenty-front/src/modules/attachments/types/Attachment.ts @@ -1,6 +1,6 @@ export type Attachment = { id: string; - createdAt: Date; - updatedAt: Date; - deletedAt: Date | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; }; diff --git a/packages/twenty-front/src/pages/auth/VerifyEffect.tsx b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx similarity index 97% rename from packages/twenty-front/src/pages/auth/VerifyEffect.tsx rename to packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx index cda60b1bc447..81cb710659a3 100644 --- a/packages/twenty-front/src/pages/auth/VerifyEffect.tsx +++ b/packages/twenty-front/src/modules/auth/components/VerifyEffect.tsx @@ -20,7 +20,7 @@ export const VerifyEffect = () => { useEffect(() => { const getTokens = async () => { if (!loginToken) { - navigate(AppPath.SignIn); + navigate(AppPath.SignInUp); } else { await verify(loginToken); diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/authorizeApp.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/authorizeApp.ts new file mode 100644 index 000000000000..5beaadc818be --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/authorizeApp.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const AUTHORIZE_APP = gql` + mutation authorizeApp($clientId: String!, $codeChallenge: String!) { + authorizeApp(clientId: $clientId, codeChallenge: $codeChallenge) { + redirectUrl + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts new file mode 100644 index 000000000000..7f7d19ae71be --- /dev/null +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/generateJWT.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const GENERATE_JWT = gql` + mutation GenerateJWT($workspaceId: String!) { + generateJWT(workspaceId: $workspaceId) { + tokens { + ...AuthTokensFragment + } + } + } +`; diff --git a/packages/twenty-front/src/modules/auth/graphql/mutations/renewToken.ts b/packages/twenty-front/src/modules/auth/graphql/mutations/renewToken.ts index 6e7048faf932..46bf00ffe44e 100644 --- a/packages/twenty-front/src/modules/auth/graphql/mutations/renewToken.ts +++ b/packages/twenty-front/src/modules/auth/graphql/mutations/renewToken.ts @@ -1,8 +1,8 @@ import { gql } from '@apollo/client'; export const RENEW_TOKEN = gql` - mutation RenewToken($refreshToken: String!) { - renewToken(refreshToken: $refreshToken) { + mutation RenewToken($appToken: String!) { + renewToken(appToken: $appToken) { tokens { ...AuthTokensFragment } diff --git a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts index ab47fa3afd21..c57337c914c9 100644 --- a/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts +++ b/packages/twenty-front/src/modules/auth/hooks/__test__/useOnboardingStatus.test.ts @@ -21,6 +21,9 @@ const currentWorkspace = { activationStatus: 'active', id: '1', allowImpersonation: true, + currentBillingSubscription: { + status: 'trialing', + }, }; const currentWorkspaceMember = { id: '1', @@ -104,7 +107,13 @@ describe('useOnboardingStatus', () => { ...currentWorkspace, subscriptionStatus: 'canceled', }); - setCurrentWorkspaceMember(currentWorkspaceMember); + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + name: { + firstName: 'John', + lastName: 'Doe', + }, + }); }); expect(result.current.onboardingStatus).toBe('canceled'); @@ -178,4 +187,91 @@ describe('useOnboardingStatus', () => { expect(result.current.onboardingStatus).toBe('completed'); }); + + it('should return "past_due"', async () => { + const { result } = renderHooks(); + const { + setTokenPair, + setBilling, + setCurrentWorkspace, + setCurrentWorkspaceMember, + } = result.current; + + act(() => { + setTokenPair(tokenPair); + setBilling(billing); + setCurrentWorkspace({ + ...currentWorkspace, + subscriptionStatus: 'past_due', + }); + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + name: { + firstName: 'John', + lastName: 'Doe', + }, + }); + }); + + expect(result.current.onboardingStatus).toBe('past_due'); + }); + + it('should return "unpaid"', async () => { + const { result } = renderHooks(); + const { + setTokenPair, + setBilling, + setCurrentWorkspace, + setCurrentWorkspaceMember, + } = result.current; + + act(() => { + setTokenPair(tokenPair); + setBilling(billing); + setCurrentWorkspace({ + ...currentWorkspace, + subscriptionStatus: 'unpaid', + }); + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + name: { + firstName: 'John', + lastName: 'Doe', + }, + }); + }); + + expect(result.current.onboardingStatus).toBe('unpaid'); + }); + + it('should return "completed_without_subscription"', async () => { + const { result } = renderHooks(); + const { + setTokenPair, + setBilling, + setCurrentWorkspace, + setCurrentWorkspaceMember, + } = result.current; + + act(() => { + setTokenPair(tokenPair); + setBilling(billing); + setCurrentWorkspace({ + ...currentWorkspace, + subscriptionStatus: 'trialing', + currentBillingSubscription: null, + }); + setCurrentWorkspaceMember({ + ...currentWorkspaceMember, + name: { + firstName: 'John', + lastName: 'Doe', + }, + }); + }); + + expect(result.current.onboardingStatus).toBe( + 'completed_without_subscription', + ); + }); }); diff --git a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts index 33c1170d8558..dce9f59424dd 100644 --- a/packages/twenty-front/src/modules/auth/hooks/useAuth.ts +++ b/packages/twenty-front/src/modules/auth/hooks/useAuth.ts @@ -10,22 +10,26 @@ import { import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState.ts'; import { isVerifyPendingState } from '@/auth/states/isVerifyPendingState'; +import { workspacesState } from '@/auth/states/workspaces'; import { authProvidersState } from '@/client-config/states/authProvidersState'; import { billingState } from '@/client-config/states/billingState'; +import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; import { isDebugModeState } from '@/client-config/states/isDebugModeState'; import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; import { supportChatState } from '@/client-config/states/supportChatState'; import { telemetryState } from '@/client-config/states/telemetryState'; import { iconsState } from '@/ui/display/icon/states/iconsState'; import { ColorScheme } from '@/workspace-member/types/WorkspaceMember'; -import { REACT_APP_SERVER_AUTH_URL } from '~/config'; +import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { useChallengeMutation, useCheckUserExistsLazyQuery, useSignUpMutation, useVerifyMutation, } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; import { currentUserState } from '../states/currentUserState'; import { tokenPairState } from '../states/tokenPairState'; @@ -39,6 +43,7 @@ export const useAuth = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); const setIsVerifyPendingState = useSetRecoilState(isVerifyPendingState); + const setWorkspaces = useSetRecoilState(workspacesState); const [challenge] = useChallengeMutation(); const [signUp] = useSignUpMutation(); @@ -59,7 +64,7 @@ export const useAuth = () => { }, }); - if (challengeResult.errors) { + if (isDefined(challengeResult.errors)) { throw challengeResult.errors; } @@ -78,7 +83,7 @@ export const useAuth = () => { variables: { loginToken }, }); - if (verifyResult.errors) { + if (isDefined(verifyResult.errors)) { throw verifyResult.errors; } @@ -91,7 +96,7 @@ export const useAuth = () => { const user = verifyResult.data?.verify.user; let workspaceMember = null; setCurrentUser(user); - if (user.workspaceMember) { + if (isDefined(user.workspaceMember)) { workspaceMember = { ...user.workspaceMember, colorScheme: user.workspaceMember?.colorScheme as ColorScheme, @@ -100,6 +105,16 @@ export const useAuth = () => { } const workspace = user.defaultWorkspace ?? null; setCurrentWorkspace(workspace); + if (isDefined(verifyResult.data?.verify.user.workspaces)) { + const validWorkspaces = verifyResult.data?.verify.user.workspaces + .filter( + ({ workspace }) => workspace !== null && workspace !== undefined, + ) + .map((validWorkspace) => validWorkspace.workspace) + .filter(isDefined); + + setWorkspaces(validWorkspaces); + } return { user, workspaceMember, @@ -113,6 +128,7 @@ export const useAuth = () => { setCurrentUser, setCurrentWorkspaceMember, setCurrentWorkspace, + setWorkspaces, ], ); @@ -151,8 +167,16 @@ export const useAuth = () => { const supportChat = snapshot.getLoadable(supportChatState).getValue(); const telemetry = snapshot.getLoadable(telemetryState).getValue(); const isDebugMode = snapshot.getLoadable(isDebugModeState).getValue(); + const isClientConfigLoaded = snapshot + .getLoadable(isClientConfigLoadedState) + .getValue(); + const isCurrentUserLoaded = snapshot + .getLoadable(isCurrentUserLoadedState) + .getValue(); const initialSnapshot = emptySnapshot.map(({ set }) => { + set(isClientConfigLoadedState, isClientConfigLoaded); + set(isCurrentUserLoadedState, isCurrentUserLoaded); set(iconsState, iconsValue); set(authProvidersState, authProvidersValue); set(billingState, billing); @@ -183,7 +207,7 @@ export const useAuth = () => { }, }); - if (signUpResult.errors) { + if (isDefined(signUpResult.errors)) { throw signUpResult.errors; } @@ -203,9 +227,9 @@ export const useAuth = () => { ); const handleGoogleLogin = useCallback((workspaceInviteHash?: string) => { - const authServerUrl = REACT_APP_SERVER_AUTH_URL; + const authServerUrl = REACT_APP_SERVER_BASE_URL; window.location.href = - `${authServerUrl}/google/${ + `${authServerUrl}/auth/google/${ workspaceInviteHash ? '?inviteHash=' + workspaceInviteHash : '' }` || ''; }, []); diff --git a/packages/twenty-front/src/modules/auth/services/AuthService.ts b/packages/twenty-front/src/modules/auth/services/AuthService.ts index d39338d5090a..c4c8f1f43a13 100644 --- a/packages/twenty-front/src/modules/auth/services/AuthService.ts +++ b/packages/twenty-front/src/modules/auth/services/AuthService.ts @@ -13,15 +13,11 @@ import { RenewTokenMutation, RenewTokenMutationVariables, } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; const logger = loggerLink(() => 'Twenty-Refresh'); -/** - * Renew token mutation with custom apollo client - * @param uri string | UriFunction | undefined - * @param refreshToken string - * @returns RenewTokenMutation - */ const renewTokenMutation = async ( uri: string | UriFunction | undefined, refreshToken: string, @@ -40,23 +36,18 @@ const renewTokenMutation = async ( >({ mutation: RenewTokenDocument, variables: { - refreshToken: refreshToken, + appToken: refreshToken, }, fetchPolicy: 'network-only', }); - if (errors || !data) { + if (isDefined(errors) || isUndefinedOrNull(data)) { throw new Error('Something went wrong during token renewal'); } return data; }; -/** - * Renew token and update cookie storage - * @param uri string | UriFunction | undefined - * @returns TokenPair - */ export const renewToken = async ( uri: string | UriFunction | undefined, tokenPair: AuthTokenPair | undefined | null, @@ -67,5 +58,5 @@ export const renewToken = async ( const data = await renewTokenMutation(uri, tokenPair.refreshToken.token); - return data.renewToken.tokens; + return data?.renewToken.tokens; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx index bc1997f2d483..1825542e4d8b 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/components/SignInUpForm.tsx @@ -54,6 +54,7 @@ export const SignInUpForm = () => { const { form } = useSignInUpForm(); const { + isInviteMode, signInUpStep, signInUpMode, continueWithCredentials, @@ -70,8 +71,10 @@ export const SignInUpForm = () => { } else if (signInUpStep === SignInUpStep.Email) { continueWithCredentials(); } else if (signInUpStep === SignInUpStep.Password) { - setShowErrors(true); - form.handleSubmit(submitCredentials)(); + if (!form.formState.isSubmitting) { + setShowErrors(true); + form.handleSubmit(submitCredentials)(); + } } } }; @@ -89,14 +92,21 @@ export const SignInUpForm = () => { }, [signInUpMode, signInUpStep]); const title = useMemo(() => { - if (signInUpMode === SignInUpMode.Invite) { + if (isInviteMode) { return `Join ${workspace?.displayName ?? ''} team`; } + if ( + signInUpStep === SignInUpStep.Init || + signInUpStep === SignInUpStep.Email + ) { + return 'Welcome to Twenty'; + } + return signInUpMode === SignInUpMode.SignIn ? 'Sign in to Twenty' : 'Sign up to Twenty'; - }, [signInUpMode, workspace?.displayName]); + }, [signInUpMode, workspace?.displayName, isInviteMode, signInUpStep]); const theme = useTheme(); @@ -217,7 +227,7 @@ export const SignInUpForm = () => { }} Icon={() => form.formState.isSubmitting && } disabled={ - SignInUpStep.Init + signInUpStep === SignInUpStep.Init ? false : signInUpStep === SignInUpStep.Email ? !form.watch('email') @@ -229,14 +239,14 @@ export const SignInUpForm = () => { /> - {signInUpStep === SignInUpStep.Password ? ( + {signInUpStep === SignInUpStep.Password && ( Forgot your password? - ) : ( + )} + {signInUpStep === SignInUpStep.Init && ( - By using Twenty, you agree to the Terms of Service and Data Processing - Agreement. + By using Twenty, you agree to the Terms of Service and Privacy Policy. )} diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts index b4dd9f67ec1d..0a0feb5f6323 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useHandleResetPassword.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar.tsx'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useEmailPasswordResetLinkMutation } from '~/generated/graphql.tsx'; export const useHandleResetPassword = () => { @@ -22,7 +22,7 @@ export const useHandleResetPassword = () => { variables: { email }, }); - if (data?.emailPasswordResetLink?.success) { + if (data?.emailPasswordResetLink?.success === true) { enqueueSnackBar('Password reset link has been sent to the email', { variant: 'success', }); diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts index 1b93836b03a8..ef0e9b6cc81d 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useNavigateAfterSignInUp.ts @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; import { CurrentWorkspace } from '@/auth/states/currentWorkspaceState.ts'; +import { previousUrlState } from '@/auth/states/previousUrlState'; import { billingState } from '@/client-config/states/billingState.ts'; import { AppPath } from '@/types/AppPath.ts'; import { WorkspaceMember } from '~/generated/graphql.tsx'; @@ -10,13 +11,14 @@ import { WorkspaceMember } from '~/generated/graphql.tsx'; export const useNavigateAfterSignInUp = () => { const navigate = useNavigate(); const billing = useRecoilValue(billingState); + const previousUrl = useRecoilValue(previousUrlState); const navigateAfterSignInUp = useCallback( ( currentWorkspace: CurrentWorkspace, currentWorkspaceMember: WorkspaceMember | null, ) => { if ( - billing?.isBillingEnabled && + billing?.isBillingEnabled === true && !['active', 'trialing'].includes(currentWorkspace.subscriptionStatus) ) { navigate(AppPath.PlanRequired); @@ -35,10 +37,10 @@ export const useNavigateAfterSignInUp = () => { navigate(AppPath.CreateProfile); return; } - - navigate(AppPath.Index); + if (previousUrl !== '') navigate(previousUrl); + else navigate(AppPath.Index); }, - [billing, navigate], + [billing, previousUrl, navigate], ); return { navigateAfterSignInUp }; }; diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx index cbc4d5192b08..c149943638ef 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUp.tsx @@ -15,7 +15,6 @@ import { useAuth } from '../../hooks/useAuth'; export enum SignInUpMode { SignIn = 'sign-in', SignUp = 'sign-up', - Invite = 'invite', } export enum SignInUpStep { @@ -33,16 +32,14 @@ export const useSignInUp = (form: UseFormReturn
) => { const { navigateAfterSignInUp } = useNavigateAfterSignInUp(); + const [isInviteMode] = useState(() => isMatchingLocation(AppPath.Invite)); + const [signInUpStep, setSignInUpStep] = useState( SignInUpStep.Init, ); const [signInUpMode, setSignInUpMode] = useState(() => { - if (isMatchingLocation(AppPath.Invite)) { - return SignInUpMode.Invite; - } - - return isMatchingLocation(AppPath.SignIn) + return isMatchingLocation(AppPath.SignInUp) ? SignInUpMode.SignIn : SignInUpMode.SignUp; }); @@ -56,7 +53,7 @@ export const useSignInUp = (form: UseFormReturn) => { const continueWithEmail = useCallback(() => { setSignInUpStep(SignInUpStep.Email); setSignInUpMode( - isMatchingLocation(AppPath.SignIn) + isMatchingLocation(AppPath.SignInUp) ? SignInUpMode.SignIn : SignInUpMode.SignUp, ); @@ -72,24 +69,14 @@ export const useSignInUp = (form: UseFormReturn) => { }, onCompleted: (data) => { if (data?.checkUserExists.exists) { - isMatchingLocation(AppPath.Invite) - ? setSignInUpMode(SignInUpMode.Invite) - : setSignInUpMode(SignInUpMode.SignIn); + setSignInUpMode(SignInUpMode.SignIn); } else { - isMatchingLocation(AppPath.Invite) - ? setSignInUpMode(SignInUpMode.Invite) - : setSignInUpMode(SignInUpMode.SignUp); + setSignInUpMode(SignInUpMode.SignUp); } setSignInUpStep(SignInUpStep.Password); }, }); - }, [ - isMatchingLocation, - setSignInUpStep, - checkUserExistsQuery, - form, - setSignInUpMode, - ]); + }, [setSignInUpStep, checkUserExistsQuery, form, setSignInUpMode]); const submitCredentials: SubmitHandler = useCallback( async (data) => { @@ -102,7 +89,7 @@ export const useSignInUp = (form: UseFormReturn) => { workspace: currentWorkspace, workspaceMember: currentWorkspaceMember, } = - signInUpMode === SignInUpMode.SignIn + signInUpMode === SignInUpMode.SignIn && !isInviteMode ? await signInWithCredentials( data.email.toLowerCase().trim(), data.password, @@ -122,6 +109,7 @@ export const useSignInUp = (form: UseFormReturn) => { }, [ signInUpMode, + isInviteMode, signInWithCredentials, signUpWithCredentials, workspaceInviteHash, @@ -156,6 +144,7 @@ export const useSignInUp = (form: UseFormReturn) => { ); return { + isInviteMode, signInUpStep, signInUpMode, continueWithCredentials, diff --git a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts index 591abf368fb2..77a555fa95b7 100644 --- a/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts +++ b/packages/twenty-front/src/modules/auth/sign-in-up/hooks/useSignInUpForm.ts @@ -29,7 +29,7 @@ export const useSignInUpForm = () => { }); useEffect(() => { - if (isSignInPrefilled) { + if (isSignInPrefilled === true) { form.setValue('email', 'tim@apple.dev'); form.setValue('password', 'Applecar2025'); } diff --git a/packages/twenty-front/src/modules/auth/states/currentUserState.ts b/packages/twenty-front/src/modules/auth/states/currentUserState.ts index db3ed7b67dec..4f1b3130e3c1 100644 --- a/packages/twenty-front/src/modules/auth/states/currentUserState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentUserState.ts @@ -1,4 +1,4 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { User } from '~/generated/graphql'; @@ -7,7 +7,7 @@ export type CurrentUser = Pick< 'id' | 'email' | 'supportUserHash' | 'canImpersonate' >; -export const currentUserState = atom({ +export const currentUserState = createState({ key: 'currentUserState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts index 938e304a6f04..37140a4501c4 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceMemberState.ts @@ -1,11 +1,11 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -export const currentWorkspaceMemberState = atom | null>({ key: 'currentWorkspaceMemberState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts index 594d16e1bb56..c9187c3ea694 100644 --- a/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts +++ b/packages/twenty-front/src/modules/auth/states/currentWorkspaceState.ts @@ -1,4 +1,4 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Workspace } from '~/generated/graphql'; @@ -12,9 +12,11 @@ export type CurrentWorkspace = Pick< | 'featureFlags' | 'subscriptionStatus' | 'activationStatus' + | 'currentBillingSubscription' + | 'currentCacheVersion' >; -export const currentWorkspaceState = atom({ +export const currentWorkspaceState = createState({ key: 'currentWorkspaceState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/auth/states/isCurrentUserLoadingState.ts b/packages/twenty-front/src/modules/auth/states/isCurrentUserLoadingState.ts new file mode 100644 index 000000000000..0a62d92ab4cf --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/isCurrentUserLoadingState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isCurrentUserLoadedState = createState({ + key: 'isCurrentUserLoadedState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/auth/states/isVerifyPendingState.ts b/packages/twenty-front/src/modules/auth/states/isVerifyPendingState.ts index 6986efab0d9c..567910389ba5 100644 --- a/packages/twenty-front/src/modules/auth/states/isVerifyPendingState.ts +++ b/packages/twenty-front/src/modules/auth/states/isVerifyPendingState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isVerifyPendingState = atom({ +export const isVerifyPendingState = createState({ key: 'isVerifyPendingState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/auth/states/previousUrlState.ts b/packages/twenty-front/src/modules/auth/states/previousUrlState.ts new file mode 100644 index 000000000000..1de274fb18c5 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/previousUrlState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const previousUrlState = createState({ + key: 'previousUrlState', + defaultValue: '', +}); diff --git a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts index 2bf851df4212..f6262b5aef32 100644 --- a/packages/twenty-front/src/modules/auth/states/tokenPairState.ts +++ b/packages/twenty-front/src/modules/auth/states/tokenPairState.ts @@ -1,10 +1,10 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { AuthTokenPair } from '~/generated/graphql'; import { cookieStorageEffect } from '~/utils/recoil-effects'; -export const tokenPairState = atom({ +export const tokenPairState = createState({ key: 'tokenPairState', - default: null, + defaultValue: null, effects: [cookieStorageEffect('tokenPair')], }); diff --git a/packages/twenty-front/src/modules/auth/states/workspaces.ts b/packages/twenty-front/src/modules/auth/states/workspaces.ts new file mode 100644 index 000000000000..d211351b08d2 --- /dev/null +++ b/packages/twenty-front/src/modules/auth/states/workspaces.ts @@ -0,0 +1,10 @@ +import { createState } from 'twenty-ui'; + +import { Workspace } from '~/generated/graphql'; + +export type Workspaces = Pick; + +export const workspacesState = createState({ + key: 'workspacesState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts index 4479e730e5cd..1f12af76b291 100644 --- a/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts +++ b/packages/twenty-front/src/modules/auth/utils/getOnboardingStatus.ts @@ -4,10 +4,13 @@ import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; export enum OnboardingStatus { Incomplete = 'incomplete', Canceled = 'canceled', + Unpaid = 'unpaid', + PastDue = 'past_due', OngoingUserCreation = 'ongoing_user_creation', OngoingWorkspaceActivation = 'ongoing_workspace_activation', OngoingProfileCreation = 'ongoing_profile_creation', Completed = 'completed', + CompletedWithoutSubscription = 'completed_without_subscription', } export const getOnboardingStatus = ({ @@ -35,16 +38,12 @@ export const getOnboardingStatus = ({ } if ( - isBillingEnabled && + isBillingEnabled === true && currentWorkspace.subscriptionStatus === 'incomplete' ) { return OnboardingStatus.Incomplete; } - if (isBillingEnabled && currentWorkspace.subscriptionStatus === 'canceled') { - return OnboardingStatus.Canceled; - } - if (currentWorkspace.activationStatus !== 'active') { return OnboardingStatus.OngoingWorkspaceActivation; } @@ -56,5 +55,33 @@ export const getOnboardingStatus = ({ return OnboardingStatus.OngoingProfileCreation; } + if ( + isBillingEnabled === true && + currentWorkspace.subscriptionStatus === 'canceled' + ) { + return OnboardingStatus.Canceled; + } + + if ( + isBillingEnabled === true && + currentWorkspace.subscriptionStatus === 'past_due' + ) { + return OnboardingStatus.PastDue; + } + + if ( + isBillingEnabled === true && + currentWorkspace.subscriptionStatus === 'unpaid' + ) { + return OnboardingStatus.Unpaid; + } + + if ( + isBillingEnabled === true && + !currentWorkspace.currentBillingSubscription + ) { + return OnboardingStatus.CompletedWithoutSubscription; + } + return OnboardingStatus.Completed; }; diff --git a/packages/twenty-front/src/modules/billing/assets/cover-dark.png b/packages/twenty-front/src/modules/billing/assets/cover-dark.png new file mode 100644 index 000000000000..d824a67048e0 Binary files /dev/null and b/packages/twenty-front/src/modules/billing/assets/cover-dark.png differ diff --git a/packages/twenty-front/src/modules/billing/assets/cover-light.png b/packages/twenty-front/src/modules/billing/assets/cover-light.png new file mode 100644 index 000000000000..31c4e56dd297 Binary files /dev/null and b/packages/twenty-front/src/modules/billing/assets/cover-light.png differ diff --git a/packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx b/packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx new file mode 100644 index 000000000000..b48ced04dd25 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/components/SettingsBillingCoverImage.tsx @@ -0,0 +1,22 @@ +import styled from '@emotion/styled'; + +import DarkCoverImage from '@/billing/assets/cover-dark.png'; +import LightCoverImage from '@/billing/assets/cover-light.png'; + +const StyledCoverImageContainer = styled.div` + align-items: center; + background-image: ${({ theme }) => + theme.name === 'light' + ? `url('${LightCoverImage.toString()}')` + : `url('${DarkCoverImage.toString()}')`}; + background-size: contain; + background-repeat: no-repeat; + box-sizing: border-box; + display: flex; + height: 162px; + justify-content: center; + position: relative; +`; +export const SettingsBillingCoverImage = () => { + return ; +}; diff --git a/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx b/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx index 490d8f304790..f4feac748e1b 100644 --- a/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx +++ b/packages/twenty-front/src/modules/billing/components/SubscriptionBenefit.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; - -import { IconCheck } from '@/ui/display/icon'; +import { IconCheck } from 'twenty-ui'; const StyledBenefitContainer = styled.div` color: ${({ theme }) => theme.font.color.secondary}; diff --git a/packages/twenty-front/src/modules/billing/graphql/billingPortalSession.ts b/packages/twenty-front/src/modules/billing/graphql/billingPortalSession.ts new file mode 100644 index 000000000000..ba4f9ff3a1c0 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/billingPortalSession.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const BILLING_PORTAL_SESSION = gql` + query BillingPortalSession($returnUrlPath: String) { + billingPortalSession(returnUrlPath: $returnUrlPath) { + url + } + } +`; diff --git a/packages/twenty-front/src/modules/billing/graphql/checkout.ts b/packages/twenty-front/src/modules/billing/graphql/checkout.ts deleted file mode 100644 index e2742d107494..000000000000 --- a/packages/twenty-front/src/modules/billing/graphql/checkout.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { gql } from '@apollo/client'; - -export const CHECKOUT = gql` - mutation Checkout($recurringInterval: String!, $successUrlPath: String) { - checkout( - recurringInterval: $recurringInterval - successUrlPath: $successUrlPath - ) { - url - } - } -`; diff --git a/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts b/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts new file mode 100644 index 000000000000..bff619a82f85 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/checkoutSession.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +export const CHECKOUT_SESSION = gql` + mutation CheckoutSession( + $recurringInterval: String! + $successUrlPath: String + ) { + checkoutSession( + recurringInterval: $recurringInterval + successUrlPath: $successUrlPath + ) { + url + } + } +`; diff --git a/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts new file mode 100644 index 000000000000..2f75c7610b37 --- /dev/null +++ b/packages/twenty-front/src/modules/billing/graphql/updateBillingSubscription.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const UPDATE_BILLING_SUBSCRIPTION = gql` + mutation UpdateBillingSubscription { + updateBillingSubscription { + success + } + } +`; diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx index f665c2b4e34c..671842687435 100644 --- a/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProvider.tsx @@ -1,63 +1,11 @@ -import { useEffect } from 'react'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue } from 'recoil'; -import { authProvidersState } from '@/client-config/states/authProvidersState'; -import { billingState } from '@/client-config/states/billingState'; -import { isDebugModeState } from '@/client-config/states/isDebugModeState'; -import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; -import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; -import { sentryConfigState } from '@/client-config/states/sentryConfigState'; -import { supportChatState } from '@/client-config/states/supportChatState'; -import { telemetryState } from '@/client-config/states/telemetryState'; -import { useGetClientConfigQuery } from '~/generated/graphql'; +import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; export const ClientConfigProvider: React.FC = ({ children, }) => { - const setAuthProviders = useSetRecoilState(authProvidersState); - const setIsDebugMode = useSetRecoilState(isDebugModeState); + const isClientConfigLoaded = useRecoilValue(isClientConfigLoadedState); - const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState); - const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState); - - const setBilling = useSetRecoilState(billingState); - const setTelemetry = useSetRecoilState(telemetryState); - const setSupportChat = useSetRecoilState(supportChatState); - - const setSentryConfig = useSetRecoilState(sentryConfigState); - - const { data, loading } = useGetClientConfigQuery(); - - useEffect(() => { - if (data?.clientConfig) { - setAuthProviders({ - google: data?.clientConfig.authProviders.google, - password: data?.clientConfig.authProviders.password, - magicLink: false, - }); - setIsDebugMode(data?.clientConfig.debugMode); - setIsSignInPrefilled(data?.clientConfig.signInPrefilled); - setIsSignUpDisabled(data?.clientConfig.signUpDisabled); - - setBilling(data?.clientConfig.billing); - setTelemetry(data?.clientConfig.telemetry); - setSupportChat(data?.clientConfig.support); - - setSentryConfig({ - dsn: data?.clientConfig?.sentry?.dsn, - }); - } - }, [ - data, - setAuthProviders, - setIsDebugMode, - setIsSignInPrefilled, - setIsSignUpDisabled, - setTelemetry, - setSupportChat, - setBilling, - setSentryConfig, - ]); - - return loading ? <> : <>{children}; + return isClientConfigLoaded ? <>{children} : <>; }; diff --git a/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx new file mode 100644 index 000000000000..976921d40742 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/components/ClientConfigProviderEffect.tsx @@ -0,0 +1,73 @@ +import { useEffect } from 'react'; +import { useRecoilState, useSetRecoilState } from 'recoil'; + +import { authProvidersState } from '@/client-config/states/authProvidersState'; +import { billingState } from '@/client-config/states/billingState'; +import { isClientConfigLoadedState } from '@/client-config/states/isClientConfigLoadedState'; +import { isDebugModeState } from '@/client-config/states/isDebugModeState'; +import { isSignInPrefilledState } from '@/client-config/states/isSignInPrefilledState'; +import { isSignUpDisabledState } from '@/client-config/states/isSignUpDisabledState'; +import { sentryConfigState } from '@/client-config/states/sentryConfigState'; +import { supportChatState } from '@/client-config/states/supportChatState'; +import { telemetryState } from '@/client-config/states/telemetryState'; +import { useGetClientConfigQuery } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +export const ClientConfigProviderEffect = () => { + const setAuthProviders = useSetRecoilState(authProvidersState); + const setIsDebugMode = useSetRecoilState(isDebugModeState); + + const setIsSignInPrefilled = useSetRecoilState(isSignInPrefilledState); + const setIsSignUpDisabled = useSetRecoilState(isSignUpDisabledState); + + const setBilling = useSetRecoilState(billingState); + const setTelemetry = useSetRecoilState(telemetryState); + const setSupportChat = useSetRecoilState(supportChatState); + + const setSentryConfig = useSetRecoilState(sentryConfigState); + const [isClientConfigLoaded, setIsClientConfigLoaded] = useRecoilState( + isClientConfigLoadedState, + ); + + const { data, loading } = useGetClientConfigQuery({ + skip: isClientConfigLoaded, + }); + + useEffect(() => { + if (!loading && isDefined(data?.clientConfig)) { + setIsClientConfigLoaded(true); + setAuthProviders({ + google: data?.clientConfig.authProviders.google, + password: data?.clientConfig.authProviders.password, + magicLink: false, + }); + setIsDebugMode(data?.clientConfig.debugMode); + setIsSignInPrefilled(data?.clientConfig.signInPrefilled); + setIsSignUpDisabled(data?.clientConfig.signUpDisabled); + + setBilling(data?.clientConfig.billing); + setTelemetry(data?.clientConfig.telemetry); + setSupportChat(data?.clientConfig.support); + + setSentryConfig({ + dsn: data?.clientConfig?.sentry?.dsn, + release: data?.clientConfig?.sentry?.release, + environment: data?.clientConfig?.sentry?.environment, + }); + } + }, [ + data, + setAuthProviders, + setIsDebugMode, + setIsSignInPrefilled, + setIsSignUpDisabled, + setTelemetry, + setSupportChat, + setBilling, + setSentryConfig, + loading, + setIsClientConfigLoaded, + ]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts index aca84198a529..b076a544559a 100644 --- a/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts +++ b/packages/twenty-front/src/modules/client-config/graphql/queries/getClientConfig.ts @@ -25,6 +25,8 @@ export const GET_CLIENT_CONFIG = gql` } sentry { dsn + environment + release } } } diff --git a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts index e0b087fa4073..6ea130303ac6 100644 --- a/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts +++ b/packages/twenty-front/src/modules/client-config/states/authProvidersState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { AuthProviders } from '~/generated/graphql'; -export const authProvidersState = atom({ +export const authProvidersState = createState({ key: 'authProvidersState', - default: { google: false, magicLink: false, password: true }, + defaultValue: { google: false, magicLink: false, password: true }, }); diff --git a/packages/twenty-front/src/modules/client-config/states/billingState.ts b/packages/twenty-front/src/modules/client-config/states/billingState.ts index 4f0982b4978a..5634c510b33e 100644 --- a/packages/twenty-front/src/modules/client-config/states/billingState.ts +++ b/packages/twenty-front/src/modules/client-config/states/billingState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Billing } from '~/generated/graphql'; -export const billingState = atom({ +export const billingState = createState({ key: 'billingState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/client-config/states/isClientConfigLoadedState.ts b/packages/twenty-front/src/modules/client-config/states/isClientConfigLoadedState.ts new file mode 100644 index 000000000000..7b6cff0b3612 --- /dev/null +++ b/packages/twenty-front/src/modules/client-config/states/isClientConfigLoadedState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const isClientConfigLoadedState = createState({ + key: 'isClientConfigLoadedState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/client-config/states/isDebugModeState.ts b/packages/twenty-front/src/modules/client-config/states/isDebugModeState.ts index 82d8be49d12e..b2efbf8fc883 100644 --- a/packages/twenty-front/src/modules/client-config/states/isDebugModeState.ts +++ b/packages/twenty-front/src/modules/client-config/states/isDebugModeState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isDebugModeState = atom({ +export const isDebugModeState = createState({ key: 'isDebugModeState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/client-config/states/isSignInPrefilledState.ts b/packages/twenty-front/src/modules/client-config/states/isSignInPrefilledState.ts index 649f42d25c05..5105b617aef7 100644 --- a/packages/twenty-front/src/modules/client-config/states/isSignInPrefilledState.ts +++ b/packages/twenty-front/src/modules/client-config/states/isSignInPrefilledState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isSignInPrefilledState = atom({ +export const isSignInPrefilledState = createState({ key: 'isSignInPrefilledState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts b/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts index da7ec103e0ce..a82b1a821072 100644 --- a/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts +++ b/packages/twenty-front/src/modules/client-config/states/isSignUpDisabledState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isSignUpDisabledState = atom({ +export const isSignUpDisabledState = createState({ key: 'isSignUpDisabledState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/client-config/states/sentryConfigState.ts b/packages/twenty-front/src/modules/client-config/states/sentryConfigState.ts index e88fd248391f..19ca0359955c 100644 --- a/packages/twenty-front/src/modules/client-config/states/sentryConfigState.ts +++ b/packages/twenty-front/src/modules/client-config/states/sentryConfigState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Sentry } from '~/generated/graphql'; -export const sentryConfigState = atom({ +export const sentryConfigState = createState({ key: 'sentryConfigState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/client-config/states/supportChatState.ts b/packages/twenty-front/src/modules/client-config/states/supportChatState.ts index 8597704935c0..cca337ba5816 100644 --- a/packages/twenty-front/src/modules/client-config/states/supportChatState.ts +++ b/packages/twenty-front/src/modules/client-config/states/supportChatState.ts @@ -1,10 +1,10 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Support } from '~/generated/graphql'; -export const supportChatState = atom({ +export const supportChatState = createState({ key: 'supportChatState', - default: { + defaultValue: { supportDriver: 'none', supportFrontChatId: null, }, diff --git a/packages/twenty-front/src/modules/client-config/states/telemetryState.ts b/packages/twenty-front/src/modules/client-config/states/telemetryState.ts index c6d095807951..927cab28b43a 100644 --- a/packages/twenty-front/src/modules/client-config/states/telemetryState.ts +++ b/packages/twenty-front/src/modules/client-config/states/telemetryState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Telemetry } from '~/generated/graphql'; -export const telemetryState = atom({ +export const telemetryState = createState({ key: 'telemetryState', - default: { enabled: true, anonymizationEnabled: true }, + defaultValue: { enabled: true, anonymizationEnabled: true }, }); diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx index 15bc43d088c6..d198cbbf6397 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenu.tsx @@ -1,17 +1,19 @@ -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef } from 'react'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useRecoilState, useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; +import { IconNotes } from 'twenty-ui'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { Activity } from '@/activities/types/Activity'; +import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { Company } from '@/companies/types/Company'; import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; import { Person } from '@/people/types/Person'; -import { IconNotes } from '@/ui/display/icon'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -21,6 +23,7 @@ import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { Avatar } from '@/users/components/Avatar'; import { getLogoUrlFromDomainName } from '~/utils'; +import { isDefined } from '~/utils/isDefined'; import { useCommandMenu } from '../hooks/useCommandMenu'; import { commandMenuCommandsState } from '../states/commandMenuCommandsState'; @@ -86,7 +89,8 @@ export const StyledList = styled.div` export const StyledInnerList = styled.div` padding-left: ${({ theme }) => theme.spacing(1)}; - width: 100%; + padding-right: ${({ theme }) => theme.spacing(1)}; + width: calc(100% - ${({ theme }) => theme.spacing(2)}); `; export const StyledEmpty = styled.div` @@ -105,23 +109,24 @@ export const CommandMenu = () => { const openActivityRightDrawer = useOpenActivityRightDrawer(); const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); - const [search, setSearch] = useState(''); + const [commandMenuSearch, setCommandMenuSearch] = useRecoilState( + commandMenuSearchState, + ); const commandMenuCommands = useRecoilValue(commandMenuCommandsState); const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu(); const handleSearchChange = (event: React.ChangeEvent) => { - setSearch(event.target.value); + setCommandMenuSearch(event.target.value); }; useScopedHotkeys( 'ctrl+k,meta+k', () => { closeKeyboardShortcutMenu(); - setSearch(''); toggleCommandMenu(); }, AppHotkeyScope.CommandMenu, - [toggleCommandMenu, setSearch], + [toggleCommandMenu], ); useScopedHotkeys( @@ -136,12 +141,12 @@ export const CommandMenu = () => { const { records: people } = useFindManyRecords({ skip: !isCommandMenuOpened, objectNameSingular: CoreObjectNameSingular.Person, - filter: search + filter: commandMenuSearch ? makeOrFilterVariables([ - { name: { firstName: { ilike: `%${search}%` } } }, - { name: { lastName: { ilike: `%${search}%` } } }, - { email: { ilike: `%${search}%` } }, - { phone: { ilike: `%${search}%` } }, + { name: { firstName: { ilike: `%${commandMenuSearch}%` } } }, + { name: { lastName: { ilike: `%${commandMenuSearch}%` } } }, + { email: { ilike: `%${commandMenuSearch}%` } }, + { phone: { ilike: `%${commandMenuSearch}%` } }, ]) : undefined, limit: 3, @@ -150,9 +155,9 @@ export const CommandMenu = () => { const { records: companies } = useFindManyRecords({ skip: !isCommandMenuOpened, objectNameSingular: CoreObjectNameSingular.Company, - filter: search + filter: commandMenuSearch ? { - name: { ilike: `%${search}%` }, + name: { ilike: `%${commandMenuSearch}%` }, } : undefined, limit: 3, @@ -161,10 +166,10 @@ export const CommandMenu = () => { const { records: activities } = useFindManyRecords({ skip: !isCommandMenuOpened, objectNameSingular: CoreObjectNameSingular.Activity, - filter: search + filter: commandMenuSearch ? makeOrFilterVariables([ - { title: { ilike: `%${search}%` } }, - { body: { ilike: `%${search}%` } }, + { title: { ilike: `%${commandMenuSearch}%` } }, + { body: { ilike: `%${commandMenuSearch}%` } }, ]) : undefined, limit: 3, @@ -216,7 +221,7 @@ export const CommandMenu = () => { }; const checkInLabels = (cmd: Command, search: string) => { - if (cmd.label) { + if (isNonEmptyString(cmd.label)) { return cmd.label.toLowerCase().includes(search.toLowerCase()); } return false; @@ -224,15 +229,17 @@ export const CommandMenu = () => { const matchingNavigateCommand = commandMenuCommands.filter( (cmd) => - (search.length > 0 - ? checkInShortcuts(cmd, search) || checkInLabels(cmd, search) + (commandMenuSearch.length > 0 + ? checkInShortcuts(cmd, commandMenuSearch) || + checkInLabels(cmd, commandMenuSearch) : true) && cmd.type === CommandType.Navigate, ); const matchingCreateCommand = commandMenuCommands.filter( (cmd) => - (search.length > 0 - ? checkInShortcuts(cmd, search) || checkInLabels(cmd, search) + (commandMenuSearch.length > 0 + ? checkInShortcuts(cmd, commandMenuSearch) || + checkInLabels(cmd, commandMenuSearch) : true) && cmd.type === CommandType.Create, ); @@ -254,7 +261,7 @@ export const CommandMenu = () => { @@ -272,7 +279,7 @@ export const CommandMenu = () => { ...otherCommands, ].find((cmd) => cmd.id === itemId); - if (command) { + if (isDefined(command)) { const { to, onCommandClick } = command; onItemClick(onCommandClick, to); } diff --git a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx index 52e4a307c3fb..e19cea30940f 100644 --- a/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/CommandMenuItem.tsx @@ -1,6 +1,7 @@ +import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; +import { IconArrowUpRight } from 'twenty-ui'; -import { IconArrowUpRight } from '@/ui/display/icon'; import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { MenuItemCommand } from '@/ui/navigation/menu-item/components/MenuItemCommand'; @@ -28,7 +29,7 @@ export const CommandMenuItem = ({ }: CommandMenuItemProps) => { const { onItemClick } = useCommandMenu(); - if (to && !Icon) { + if (isNonEmptyString(to) && !Icon) { Icon = IconArrowUpRight; } diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index 6cf5c7fbf3c5..1ce66e1ecf00 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -1,13 +1,14 @@ import { useEffect } from 'react'; +import { action } from '@storybook/addon-actions'; import { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; import { useSetRecoilState } from 'recoil'; +import { IconCheckbox, IconNotes } from 'twenty-ui'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { CommandType } from '@/command-menu/types/Command'; -import { IconCheckbox, IconNotes } from '@/ui/display/icon'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -46,7 +47,7 @@ const meta: Meta = { label: 'Create Task', type: CommandType.Create, Icon: IconCheckbox, - onCommandClick: () => console.log('create task click'), + onCommandClick: action('create task click'), }, { id: 'create-note', @@ -54,7 +55,7 @@ const meta: Meta = { label: 'Create Note', type: CommandType.Create, Icon: IconNotes, - onCommandClick: () => console.log('create note click'), + onCommandClick: action('create note click'), }, ]); openCommandMenu(); diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuCommands.ts b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuCommands.ts index 41fed702090c..6cfc5528098c 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuCommands.ts +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuCommands.ts @@ -4,7 +4,7 @@ import { IconSettings, IconTargetArrow, IconUser, -} from '@/ui/display/icon'; +} from 'twenty-ui'; import { Command, CommandType } from '../types/Command'; diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts index 0d806f586be3..1a8e085064e2 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenu.ts @@ -1,10 +1,13 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; +import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; +import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope'; +import { isDefined } from '~/utils/isDefined'; import { COMMAND_MENU_COMMANDS } from '../constants/CommandMenuCommands'; import { commandMenuCommandsState } from '../states/commandMenuCommandsState'; @@ -21,10 +24,10 @@ export const useCommandMenu = () => { goBackToPreviousHotkeyScope, } = usePreviousHotkeyScope(); - const openCommandMenu = () => { + const openCommandMenu = useCallback(() => { setIsCommandMenuOpened(true); setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.CommandMenuOpen); - }; + }, [setHotkeyScopeAndMemorizePreviousScope, setIsCommandMenuOpened]); const closeCommandMenu = useRecoilCallback( ({ snapshot }) => @@ -42,17 +45,23 @@ export const useCommandMenu = () => { [goBackToPreviousHotkeyScope, resetSelectedItem, setIsCommandMenuOpened], ); - const toggleCommandMenu = useRecoilCallback(({ snapshot }) => async () => { - const isCommandMenuOpened = snapshot - .getLoadable(isCommandMenuOpenedState) - .getValue(); + const toggleCommandMenu = useRecoilCallback( + ({ snapshot, set }) => + async () => { + const isCommandMenuOpened = snapshot + .getLoadable(isCommandMenuOpenedState) + .getValue(); + + set(commandMenuSearchState, ''); - if (isCommandMenuOpened) { - closeCommandMenu(); - } else { - openCommandMenu(); - } - }); + if (isCommandMenuOpened) { + closeCommandMenu(); + } else { + openCommandMenu(); + } + }, + [closeCommandMenu, openCommandMenu], + ); const addToCommandMenu = useCallback( (addCommand: Command[]) => { @@ -69,11 +78,11 @@ export const useCommandMenu = () => { (onClick?: () => void, to?: string) => { toggleCommandMenu(); - if (onClick) { + if (isDefined(onClick)) { onClick(); return; } - if (to) { + if (isNonEmptyString(to)) { navigate(to); return; } diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuCommandsState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuCommandsState.ts index cfd4c3596136..309754be2c67 100644 --- a/packages/twenty-front/src/modules/command-menu/states/commandMenuCommandsState.ts +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuCommandsState.ts @@ -1,10 +1,10 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Command, CommandType } from '../types/Command'; -export const commandMenuCommandsState = atom({ +export const commandMenuCommandsState = createState({ key: 'command-menu/commandMenuCommandsState', - default: [ + defaultValue: [ { id: '', to: '', diff --git a/packages/twenty-front/src/modules/command-menu/states/commandMenuSearchState.ts b/packages/twenty-front/src/modules/command-menu/states/commandMenuSearchState.ts new file mode 100644 index 000000000000..61f580a8ae17 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/states/commandMenuSearchState.ts @@ -0,0 +1,6 @@ +import { createState } from 'twenty-ui'; + +export const commandMenuSearchState = createState({ + key: 'command-menu/commandMenuSearchState', + defaultValue: '', +}); diff --git a/packages/twenty-front/src/modules/companies/__stories__/Board.stories.tsx b/packages/twenty-front/src/modules/companies/__stories__/Board.stories.tsx deleted file mode 100644 index e862442481ca..000000000000 --- a/packages/twenty-front/src/modules/companies/__stories__/Board.stories.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; -import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; -import { graphqlMocks } from '~/testing/graphqlMocks'; - -import { CompanyBoard } from '../board/components/CompanyBoard'; - -const DoNotRenderEffect = () => <>; - -const meta: Meta = { - title: 'Modules/Companies/Board', - component: DoNotRenderEffect, - decorators: [ComponentWithRouterDecorator, SnackBarDecorator], - parameters: { - msw: graphqlMocks, - }, -}; - -export default meta; -type Story = StoryObj; - -// FIXME: CompanyBoard is re-rendering so much and exceeding the maximum update depth for some reason. -export const OneColumnBoard: Story = {}; diff --git a/packages/twenty-front/src/modules/companies/__stories__/CompanyChip.stories.tsx b/packages/twenty-front/src/modules/companies/__stories__/CompanyChip.stories.tsx deleted file mode 100644 index 34b9e695cf68..000000000000 --- a/packages/twenty-front/src/modules/companies/__stories__/CompanyChip.stories.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; - -import { CompanyChip } from '../components/CompanyChip'; - -const meta: Meta = { - title: 'Modules/Companies/CompanyChip', - component: CompanyChip, - decorators: [ComponentWithRouterDecorator], -}; - -export default meta; -type Story = StoryObj; - -export const SmallName: Story = { - args: { - opportunityId: 'airbnb', - companyName: 'Airbnb', - avatarUrl: 'https://api.faviconkit.com/airbnb.com/144', - }, -}; - -export const BigName: Story = { - args: { - opportunityId: 'google', - companyName: 'Google with a real big name to overflow the cell', - avatarUrl: 'https://api.faviconkit.com/google.com/144', - }, -}; diff --git a/packages/twenty-front/src/modules/companies/__stories__/mock-data.ts b/packages/twenty-front/src/modules/companies/__stories__/mock-data.ts deleted file mode 100644 index 6bfc08feb6ca..000000000000 --- a/packages/twenty-front/src/modules/companies/__stories__/mock-data.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { PipelineStep } from '@/pipeline/types/PipelineStep'; - -export const pipelineSteps = [ - { - id: 'pipeline-stage-1', - name: 'New', - position: 0, - color: 'red', - }, - { - id: 'pipeline-stage-2', - name: 'Screening', - position: 1, - color: 'purple', - }, - { - id: 'pipeline-stage-3', - name: 'Meeting', - position: 2, - color: 'sky', - }, - { - id: 'pipeline-stage-4', - name: 'Proposal', - position: 3, - color: 'turquoise', - }, - { - id: 'pipeline-stage-5', - name: 'Customer', - position: 4, - color: 'yellow', - }, -] as PipelineStep[]; diff --git a/packages/twenty-front/src/modules/companies/board/components/CompanyBoard.tsx b/packages/twenty-front/src/modules/companies/board/components/CompanyBoard.tsx deleted file mode 100644 index 48bbf277f9df..000000000000 --- a/packages/twenty-front/src/modules/companies/board/components/CompanyBoard.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useCallback } from 'react'; -import styled from '@emotion/styled'; - -import { mapBoardFieldDefinitionsToViewFields } from '@/companies/utils/mapBoardFieldDefinitionsToViewFields'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { - RecordBoardDeprecated, - RecordBoardDeprecatedProps, -} from '@/object-record/record-board-deprecated/components/RecordBoardDeprecated'; -import { RecordBoardDeprecatedEffect } from '@/object-record/record-board-deprecated/components/RecordBoardDeprecatedEffect'; -import { BOARD_OPTIONS_DROPDOWN_ID } from '@/object-record/record-board-deprecated/constants/BoardOptionsDropdownId'; -import { RecordBoardDeprecatedOptionsDropdown } from '@/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdown'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; -import { ViewBar } from '@/views/components/ViewBar'; -import { useViewFields } from '@/views/hooks/internal/useViewFields'; -import { opportunitiesBoardOptions } from '~/pages/opportunities/opportunitiesBoardOptions'; - -import { HooksCompanyBoardEffect } from '../../components/HooksCompanyBoardEffect'; - -const StyledContainer = styled.div` - display: flex; - flex-direction: column; - height: 100%; - overflow: auto; - width: 100%; -`; - -type CompanyBoardProps = Pick< - RecordBoardDeprecatedProps, - 'onColumnAdd' | 'onColumnDelete' | 'onEditColumnTitle' ->; - -export const CompanyBoard = ({ - onColumnAdd, - onColumnDelete, - onEditColumnTitle, -}: CompanyBoardProps) => { - const viewBarId = 'company-board-view'; - const recordBoardId = 'company-board'; - - const { persistViewFields } = useViewFields(viewBarId); - - const { createOneRecord } = useCreateOneRecord({ - objectNameSingular: 'pipelineStep', - }); - - const onStageAdd = useCallback( - (stage: BoardColumnDefinition) => { - createOneRecord({ - name: stage.title, - color: stage.colorCode, - position: stage.position, - id: stage.id, - }); - }, - [createOneRecord], - ); - - return ( - - - } - optionsDropdownScopeId={BOARD_OPTIONS_DROPDOWN_ID} - /> - - - { - persistViewFields(mapBoardFieldDefinitionsToViewFields(fields)); - }} - /> - - - - ); -}; diff --git a/packages/twenty-front/src/modules/companies/components/CompanyBoardCard.tsx b/packages/twenty-front/src/modules/companies/components/CompanyBoardCard.tsx deleted file mode 100644 index 59e81cecf14c..000000000000 --- a/packages/twenty-front/src/modules/companies/components/CompanyBoardCard.tsx +++ /dev/null @@ -1,255 +0,0 @@ -import { ReactNode, useContext } from 'react'; -import styled from '@emotion/styled'; -import { useRecoilState, useRecoilValue } from 'recoil'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { BoardCardIdContext } from '@/object-record/record-board-deprecated/contexts/BoardCardIdContext'; -import { useCurrentRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { isRecordBoardDeprecatedCardInCompactViewFamilyState } from '@/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardInCompactViewFamilyState'; -import { - FieldContext, - RecordUpdateHook, - RecordUpdateHookParams, -} from '@/object-record/record-field/contexts/FieldContext'; -import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; -import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; -import { EntityChipVariant } from '@/ui/display/chip/components/EntityChip'; -import { IconEye } from '@/ui/display/icon/index'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; -import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; -import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; -import { getLogoUrlFromDomainName } from '~/utils'; - -import { companyProgressesFamilyState } from '../states/companyProgressesFamilyState'; - -import { CompanyChip } from './CompanyChip'; - -const StyledBoardCard = styled.div<{ selected: boolean }>` - background-color: ${({ theme, selected }) => - selected ? theme.accent.quaternary : theme.background.secondary}; - border: 1px solid - ${({ theme, selected }) => - selected ? theme.accent.secondary : theme.border.color.medium}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - box-shadow: ${({ theme }) => theme.boxShadow.light}; - color: ${({ theme }) => theme.font.color.primary}; - &:hover { - background-color: ${({ theme, selected }) => - selected && theme.accent.tertiary}; - border: 1px solid - ${({ theme, selected }) => - selected ? theme.accent.primary : theme.border.color.medium}; - } - cursor: pointer; - - .checkbox-container { - transition: all ease-in-out 160ms; - opacity: ${({ selected }) => (selected ? 1 : 0)}; - } - - &:hover .checkbox-container { - opacity: 1; - } - - .compact-icon-container { - transition: all ease-in-out 160ms; - opacity: 0; - } - &:hover .compact-icon-container { - opacity: 1; - } -`; - -const StyledBoardCardWrapper = styled.div` - padding-bottom: ${({ theme }) => theme.spacing(2)}; - width: 100%; -`; - -const StyledBoardCardHeader = styled.div<{ - showCompactView: boolean; -}>` - align-items: center; - display: flex; - flex-direction: row; - font-weight: ${({ theme }) => theme.font.weight.medium}; - height: 24px; - padding-bottom: ${({ theme, showCompactView }) => - theme.spacing(showCompactView ? 0 : 1)}; - padding-left: ${({ theme }) => theme.spacing(2)}; - padding-right: ${({ theme }) => theme.spacing(2)}; - padding-top: ${({ theme }) => theme.spacing(2)}; - transition: padding ease-in-out 160ms; - - img { - height: ${({ theme }) => theme.icon.size.md}px; - margin-right: ${({ theme }) => theme.spacing(2)}; - object-fit: cover; - width: ${({ theme }) => theme.icon.size.md}px; - } -`; - -const StyledBoardCardBody = styled.div` - display: flex; - flex-direction: column; - gap: ${({ theme }) => theme.spacing(0.5)}; - padding-bottom: ${({ theme }) => theme.spacing(2)}; - padding-left: ${({ theme }) => theme.spacing(2.5)}; - padding-right: ${({ theme }) => theme.spacing(2)}; - span { - align-items: center; - display: flex; - flex-direction: row; - svg { - color: ${({ theme }) => theme.font.color.tertiary}; - margin-right: ${({ theme }) => theme.spacing(2)}; - } - } -`; - -const StyledCheckboxContainer = styled.div` - display: flex; - flex: 1; - justify-content: end; -`; - -const StyledFieldContainer = styled.div` - display: flex; - flex-direction: row; - width: 100%; -`; - -const StyledCompactIconContainer = styled.div` - align-items: center; - display: flex; - justify-content: center; -`; - -export const CompanyBoardCard = () => { - const { isCurrentCardSelected, setCurrentCardSelected } = - useCurrentRecordBoardDeprecatedCardSelectedInternal(); - const boardCardId = useContext(BoardCardIdContext); - - const [companyProgress] = useRecoilState( - companyProgressesFamilyState(boardCardId ?? ''), - ); - - const { isCompactViewEnabledState, visibleBoardCardFieldsSelector } = - useRecordBoardDeprecatedScopedStates(); - - const [isCompactViewEnabled] = useRecoilState(isCompactViewEnabledState); - - const [isCardInCompactView, setIsCardInCompactView] = useRecoilState( - isRecordBoardDeprecatedCardInCompactViewFamilyState(boardCardId ?? ''), - ); - - const showCompactView = isCompactViewEnabled && isCardInCompactView; - - const { opportunity, company } = companyProgress ?? {}; - - const visibleBoardCardFields = useRecoilValue(visibleBoardCardFieldsSelector); - - const useUpdateOneRecordMutation: RecordUpdateHook = () => { - const { updateOneRecord: updateOneOpportunity } = useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Opportunity, - }); - - const updateEntity = ({ variables }: RecordUpdateHookParams) => { - updateOneOpportunity?.({ - idToUpdate: variables.where.id as string, - updateOneRecordInput: variables.updateOneRecordInput, - }); - }; - - return [updateEntity, { loading: false }]; - }; - - // boardCardId check can be moved to a wrapper to avoid unnecessary logic above - if (!company || !opportunity || !boardCardId) { - return null; - } - - const PreventSelectOnClickContainer = ({ - children, - }: { - children: ReactNode; - }) => ( - { - e.stopPropagation(); - }} - > - {children} - - ); - - const OnMouseLeaveBoard = () => { - setIsCardInCompactView(true); - }; - - return ( - - setCurrentCardSelected(!isCurrentCardSelected)} - > - - - {showCompactView && ( - - { - e.stopPropagation(); - setIsCardInCompactView(false); - }} - /> - - )} - - setCurrentCardSelected(!isCurrentCardSelected)} - variant={CheckboxVariant.Secondary} - /> - - - - - {visibleBoardCardFields.map((viewField) => ( - - - - - - ))} - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/companies/components/CompanyChip.tsx b/packages/twenty-front/src/modules/companies/components/CompanyChip.tsx deleted file mode 100644 index 3fd8921066a4..000000000000 --- a/packages/twenty-front/src/modules/companies/components/CompanyChip.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { - EntityChip, - EntityChipVariant, -} from '@/ui/display/chip/components/EntityChip'; - -type CompanyChipProps = { - opportunityId: string; - companyName: string; - avatarUrl?: string; - variant?: EntityChipVariant; -}; - -export const CompanyChip = ({ - opportunityId, - companyName, - avatarUrl, - variant = EntityChipVariant.Regular, -}: CompanyChipProps) => ( - -); diff --git a/packages/twenty-front/src/modules/companies/components/HooksCompanyBoardEffect.tsx b/packages/twenty-front/src/modules/companies/components/HooksCompanyBoardEffect.tsx deleted file mode 100644 index eb955d3444b6..000000000000 --- a/packages/twenty-front/src/modules/companies/components/HooksCompanyBoardEffect.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { availableRecordBoardDeprecatedCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/availableRecordBoardDeprecatedCardFieldsScopedState'; -import { recordBoardCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedCardFieldsScopedState'; -import { recordBoardFiltersScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedFiltersScopedState'; -import { recordBoardSortsScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedSortsScopedState'; -import { useSetRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useSetRecoilScopedStateV2'; -import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; -import { useViewBar } from '@/views/hooks/useViewBar'; -import { mapViewFieldsToBoardFieldDefinitions } from '@/views/utils/mapViewFieldsToBoardFieldDefinitions'; - -type HooksCompanyBoardEffectProps = { - viewBarId: string; - recordBoardId: string; -}; - -export const HooksCompanyBoardEffect = ({ - viewBarId, - recordBoardId, -}: HooksCompanyBoardEffectProps) => { - const { - setAvailableFilterDefinitions, - setAvailableSortDefinitions, - setAvailableFieldDefinitions, - setViewObjectMetadataId, - } = useViewBar({ viewBarId }); - - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: CoreObjectNameSingular.Opportunity, - }); - - const { columnDefinitions, filterDefinitions, sortDefinitions } = - useColumnDefinitionsFromFieldMetadata(objectMetadataItem); - - const setAvailableBoardCardFields = useSetRecoilScopedStateV2( - availableRecordBoardDeprecatedCardFieldsScopedState, - 'company-board', - ); - - useEffect(() => { - if (!objectMetadataItem) { - return; - } - setAvailableFilterDefinitions?.(filterDefinitions); - setAvailableSortDefinitions?.(sortDefinitions); - setAvailableFieldDefinitions?.(columnDefinitions); - }, [ - columnDefinitions, - filterDefinitions, - objectMetadataItem, - setAvailableFieldDefinitions, - setAvailableFilterDefinitions, - setAvailableSortDefinitions, - sortDefinitions, - ]); - - useEffect(() => { - setAvailableBoardCardFields(columnDefinitions); - }, [columnDefinitions, setAvailableBoardCardFields]); - - useEffect(() => { - if (!objectMetadataItem) { - return; - } - setViewObjectMetadataId?.(objectMetadataItem.id); - }, [objectMetadataItem, setViewObjectMetadataId]); - - const { - currentViewFieldsState, - currentViewFiltersState, - currentViewSortsState, - } = useViewScopedStates({ viewScopeId: viewBarId }); - - const currentViewFields = useRecoilValue(currentViewFieldsState); - const currentViewFilters = useRecoilValue(currentViewFiltersState); - const currentViewSorts = useRecoilValue(currentViewSortsState); - - //TODO: Modify to use scopeId - const setBoardCardFields = useSetRecoilScopedStateV2( - recordBoardCardFieldsScopedState, - 'company-board', - ); - const setBoardCardFilters = useSetRecoilScopedStateV2( - recordBoardFiltersScopedState, - 'company-board', - ); - - const setBoardCardSorts = useSetRecoilScopedStateV2( - recordBoardSortsScopedState, - 'company-board', - ); - - useEffect(() => { - if (currentViewFields) { - setBoardCardFields( - mapViewFieldsToBoardFieldDefinitions( - currentViewFields, - columnDefinitions, - ), - ); - } - }, [columnDefinitions, currentViewFields, setBoardCardFields]); - - useEffect(() => { - if (currentViewFilters) { - setBoardCardFilters(currentViewFilters); - } - }, [currentViewFilters, setBoardCardFilters]); - - useEffect(() => { - if (currentViewSorts) { - setBoardCardSorts(currentViewSorts); - } - }, [currentViewSorts, setBoardCardSorts]); - - const { setEntityCountInCurrentView } = useViewBar({ viewBarId }); - - const { savedOpportunitiesState } = useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: recordBoardId, - }); - - const savedOpportunities = useRecoilValue(savedOpportunitiesState); - - useEffect(() => { - setEntityCountInCurrentView(savedOpportunities.length); - }, [savedOpportunities.length, setEntityCountInCurrentView]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/companies/components/NewOpportunityButton.tsx b/packages/twenty-front/src/modules/companies/components/NewOpportunityButton.tsx deleted file mode 100644 index 193adfbed20b..000000000000 --- a/packages/twenty-front/src/modules/companies/components/NewOpportunityButton.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback, useContext, useState } from 'react'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { NewButton } from '@/object-record/record-board-deprecated/components/NewButton'; -import { BoardColumnContext } from '@/object-record/record-board-deprecated/contexts/BoardColumnContext'; -import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity'; -import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; -import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; - -export const NewOpportunityButton = () => { - const [isCreatingCard, setIsCreatingCard] = useState(false); - const column = useContext(BoardColumnContext); - - const pipelineStepId = column?.columnDefinition.id || ''; - - const { enqueueSnackBar } = useSnackBar(); - const createOpportunity = useCreateOpportunity(); - - const { - goBackToPreviousHotkeyScope, - setHotkeyScopeAndMemorizePreviousScope, - } = usePreviousHotkeyScope(); - - const handleEntitySelect = (company: any) => { - setIsCreatingCard(false); - goBackToPreviousHotkeyScope(); - - if (!pipelineStepId) { - enqueueSnackBar('Pipeline stage id is not defined', { - variant: 'error', - }); - - throw new Error('Pipeline stage id is not defined'); - } - - createOpportunity(company.id, pipelineStepId); - }; - - const handleNewClick = useCallback(() => { - setIsCreatingCard(true); - setHotkeyScopeAndMemorizePreviousScope( - RelationPickerHotkeyScope.RelationPicker, - ); - }, [setIsCreatingCard, setHotkeyScopeAndMemorizePreviousScope]); - - const handleCancel = () => { - goBackToPreviousHotkeyScope(); - setIsCreatingCard(false); - }; - - return ( - <> - {isCreatingCard ? ( - - ) : ( - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/companies/components/OpportunityPicker.tsx b/packages/twenty-front/src/modules/companies/components/OpportunityPicker.tsx deleted file mode 100644 index 2e5bc39701d7..000000000000 --- a/packages/twenty-front/src/modules/companies/components/OpportunityPicker.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useRecoilValue } from 'recoil'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { SingleEntitySelectMenuItemsWithSearch } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; -import { currentPipelineStepsState } from '@/pipeline/states/currentPipelineStepsState'; -import { IconChevronDown } from '@/ui/display/icon'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; -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 { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; - -export type OpportunityPickerProps = { - companyId: string | null; - onSubmit: ( - newCompany: EntityForSelect | null, - newPipelineStepId: string | null, - ) => void; - onCancel?: () => void; -}; - -export const OpportunityPicker = ({ - onSubmit, - onCancel, -}: OpportunityPickerProps) => { - const containerRef = useRef(null); - - const [isProgressSelectionUnfolded, setIsProgressSelectionUnfolded] = - useState(false); - - const [selectedPipelineStepId, setSelectedPipelineStepId] = useState< - string | null - >(null); - - const currentPipelineSteps = useRecoilValue(currentPipelineStepsState); - - const handlePipelineStepChange = (newPipelineStepId: string) => { - setSelectedPipelineStepId(newPipelineStepId); - setIsProgressSelectionUnfolded(false); - }; - - const handleEntitySelected = async ( - selectedCompany: EntityForSelect | null | undefined, - ) => { - onSubmit(selectedCompany ?? null, selectedPipelineStepId); - }; - - useEffect(() => { - if (currentPipelineSteps?.[0]?.id) { - setSelectedPipelineStepId(currentPipelineSteps?.[0]?.id); - } - }, [currentPipelineSteps]); - - const selectedPipelineStep = useMemo( - () => - currentPipelineSteps.find( - (pipelineStep: any) => pipelineStep.id === selectedPipelineStepId, - ), - [currentPipelineSteps, selectedPipelineStepId], - ); - - return ( - - {isProgressSelectionUnfolded ? ( - - {currentPipelineSteps.map((pipelineStep: any, index: number) => ( - { - handlePipelineStepChange(pipelineStep.id); - }} - text={pipelineStep.name} - /> - ))} - - ) : ( - <> - setIsProgressSelectionUnfolded(true)} - > - {selectedPipelineStep?.name} - - - - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/companies/editable-field/types/EditableFieldHotkeyScope.ts b/packages/twenty-front/src/modules/companies/editable-field/types/EditableFieldHotkeyScope.ts deleted file mode 100644 index 309495c8f26a..000000000000 --- a/packages/twenty-front/src/modules/companies/editable-field/types/EditableFieldHotkeyScope.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum EditableFieldHotkeyScope { - EditableField = 'editable-field', -} diff --git a/packages/twenty-front/src/modules/companies/states/companyProgressesFamilyState.ts b/packages/twenty-front/src/modules/companies/states/companyProgressesFamilyState.ts deleted file mode 100644 index 5b920ef303a3..000000000000 --- a/packages/twenty-front/src/modules/companies/states/companyProgressesFamilyState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { atomFamily } from 'recoil'; - -import { CompanyProgress } from '@/companies/types/CompanyProgress'; - -export const companyProgressesFamilyState = atomFamily< - CompanyProgress | undefined, - string ->({ - key: 'companyProgressesFamilyState', - default: undefined, -}); diff --git a/packages/twenty-front/src/modules/companies/states/recoil-scope-contexts/CompanyBoardViewBarRecoilScopeContext.ts b/packages/twenty-front/src/modules/companies/states/recoil-scope-contexts/CompanyBoardViewBarRecoilScopeContext.ts deleted file mode 100644 index d5e5436e0d5f..000000000000 --- a/packages/twenty-front/src/modules/companies/states/recoil-scope-contexts/CompanyBoardViewBarRecoilScopeContext.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createContext } from 'react'; - -export const CompanyBoardViewBarRecoilScopeContext = createContext< - string | null ->(null); diff --git a/packages/twenty-front/src/modules/companies/types/CompanyProgress.ts b/packages/twenty-front/src/modules/companies/types/CompanyProgress.ts deleted file mode 100644 index ab797dca2603..000000000000 --- a/packages/twenty-front/src/modules/companies/types/CompanyProgress.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { Company } from '@/companies/types/Company'; -import { Opportunity } from '@/pipeline/types/Opportunity'; - -export type CompanyForBoard = Pick; - -export type CompanyProgress = { - company: CompanyForBoard; - opportunity: Opportunity; -}; - -export type CompanyProgressDict = { - [key: string]: CompanyProgress; -}; diff --git a/packages/twenty-front/src/modules/companies/utils/mapBoardFieldDefinitionsToViewFields.ts b/packages/twenty-front/src/modules/companies/utils/mapBoardFieldDefinitionsToViewFields.ts deleted file mode 100644 index f02d992af8f3..000000000000 --- a/packages/twenty-front/src/modules/companies/utils/mapBoardFieldDefinitionsToViewFields.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { v4 } from 'uuid'; - -import { BoardFieldDefinition } from '@/object-record/record-board-deprecated/types/BoardFieldDefinition'; -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { ViewField } from '@/views/types/ViewField'; - -export const mapBoardFieldDefinitionsToViewFields = ( - fieldsDefinitions: BoardFieldDefinition[], -): ViewField[] => { - return fieldsDefinitions.map( - (fieldDefinition): ViewField => ({ - id: fieldDefinition.viewFieldId || v4(), - fieldMetadataId: fieldDefinition.fieldMetadataId, - size: 0, - position: fieldDefinition.position, - isVisible: fieldDefinition.isVisible ?? true, - definition: fieldDefinition, - }), - ); -}; diff --git a/packages/twenty-front/src/modules/databases/graphql/fragments/databaseConnectionFragment.ts b/packages/twenty-front/src/modules/databases/graphql/fragments/databaseConnectionFragment.ts new file mode 100644 index 000000000000..73b3c0c322a7 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/fragments/databaseConnectionFragment.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +export const DATABASE_CONNECTION_FRAGMENT = gql` + fragment RemoteServerFields on RemoteServer { + id + createdAt + foreignDataWrapperId + foreignDataWrapperOptions + foreignDataWrapperType + updatedAt + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/fragments/remoteTableFragment.ts b/packages/twenty-front/src/modules/databases/graphql/fragments/remoteTableFragment.ts new file mode 100644 index 000000000000..a14cdfa3888a --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/fragments/remoteTableFragment.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const REMOTE_TABLE_FRAGMENT = gql` + fragment RemoteTableFields on RemoteTable { + name + schema + status + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/mutations/createOneDatabaseConnection.ts b/packages/twenty-front/src/modules/databases/graphql/mutations/createOneDatabaseConnection.ts new file mode 100644 index 000000000000..4c77e0508eb3 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/mutations/createOneDatabaseConnection.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +import { DATABASE_CONNECTION_FRAGMENT } from '@/databases/graphql/fragments/databaseConnectionFragment'; + +export const CREATE_ONE_DATABASE_CONNECTION = gql` + ${DATABASE_CONNECTION_FRAGMENT} + mutation createServer($input: CreateRemoteServerInput!) { + createOneRemoteServer(input: $input) { + ...RemoteServerFields + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/mutations/deleteOneDatabaseConnection.ts b/packages/twenty-front/src/modules/databases/graphql/mutations/deleteOneDatabaseConnection.ts new file mode 100644 index 000000000000..c2f9974ba598 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/mutations/deleteOneDatabaseConnection.ts @@ -0,0 +1,9 @@ +import { gql } from '@apollo/client'; + +export const DELETE_ONE_DATABASE_CONNECTION = gql` + mutation deleteServer($input: RemoteServerIdInput!) { + deleteOneRemoteServer(input: $input) { + id + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/mutations/syncRemoteTable.ts b/packages/twenty-front/src/modules/databases/graphql/mutations/syncRemoteTable.ts new file mode 100644 index 000000000000..acb45746774a --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/mutations/syncRemoteTable.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +import { REMOTE_TABLE_FRAGMENT } from '@/databases/graphql/fragments/remoteTableFragment'; + +export const SYNC_REMOTE_TABLE = gql` + ${REMOTE_TABLE_FRAGMENT} + mutation syncRemoteTable($input: RemoteTableInput!) { + syncRemoteTable(input: $input) { + ...RemoteTableFields + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/mutations/unsyncRemoteTable.ts b/packages/twenty-front/src/modules/databases/graphql/mutations/unsyncRemoteTable.ts new file mode 100644 index 000000000000..300696154b46 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/mutations/unsyncRemoteTable.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +import { REMOTE_TABLE_FRAGMENT } from '@/databases/graphql/fragments/remoteTableFragment'; + +export const UNSYNC_REMOTE_TABLE = gql` + ${REMOTE_TABLE_FRAGMENT} + mutation unsyncRemoteTable($input: RemoteTableInput!) { + unsyncRemoteTable(input: $input) { + ...RemoteTableFields + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/queries/findManyDatabaseConnections.ts b/packages/twenty-front/src/modules/databases/graphql/queries/findManyDatabaseConnections.ts new file mode 100644 index 000000000000..5b33e4f04400 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/queries/findManyDatabaseConnections.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +import { DATABASE_CONNECTION_FRAGMENT } from '@/databases/graphql/fragments/databaseConnectionFragment'; + +export const GET_MANY_DATABASE_CONNECTIONS = gql` + ${DATABASE_CONNECTION_FRAGMENT} + query GetManyDatabaseConnections($input: RemoteServerTypeInput!) { + findManyRemoteServersByType(input: $input) { + ...RemoteServerFields + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/queries/findManyRemoteTables.ts b/packages/twenty-front/src/modules/databases/graphql/queries/findManyRemoteTables.ts new file mode 100644 index 000000000000..69eca2bf8296 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/queries/findManyRemoteTables.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +import { REMOTE_TABLE_FRAGMENT } from '@/databases/graphql/fragments/remoteTableFragment'; + +export const GET_MANY_REMOTE_TABLES = gql` + ${REMOTE_TABLE_FRAGMENT} + query GetManyRemoteTables($input: RemoteServerIdInput!) { + findAvailableRemoteTablesByServerId(input: $input) { + ...RemoteTableFields + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/graphql/queries/findOneDatabaseConnection.ts b/packages/twenty-front/src/modules/databases/graphql/queries/findOneDatabaseConnection.ts new file mode 100644 index 000000000000..4658e133b208 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/graphql/queries/findOneDatabaseConnection.ts @@ -0,0 +1,12 @@ +import { gql } from '@apollo/client'; + +import { DATABASE_CONNECTION_FRAGMENT } from '@/databases/graphql/fragments/databaseConnectionFragment'; + +export const GET_ONE_DATABASE_CONNECTION = gql` + ${DATABASE_CONNECTION_FRAGMENT} + query GetOneDatabaseConnection($input: RemoteServerIdInput!) { + findOneRemoteServerById(input: $input) { + ...RemoteServerFields + } + } +`; diff --git a/packages/twenty-front/src/modules/databases/hooks/useCreateOneDatabaseConnection.ts b/packages/twenty-front/src/modules/databases/hooks/useCreateOneDatabaseConnection.ts new file mode 100644 index 000000000000..3221c305c77a --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useCreateOneDatabaseConnection.ts @@ -0,0 +1,38 @@ +import { ApolloClient, useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; + +import { CREATE_ONE_DATABASE_CONNECTION } from '@/databases/graphql/mutations/createOneDatabaseConnection'; +import { GET_MANY_DATABASE_CONNECTIONS } from '@/databases/graphql/queries/findManyDatabaseConnections'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { + CreateRemoteServerInput, + CreateServerMutation, + CreateServerMutationVariables, +} from '~/generated-metadata/graphql'; + +export const useCreateOneDatabaseConnection = () => { + const apolloMetadataClient = useApolloMetadataClient(); + + const [mutate] = useMutation< + CreateServerMutation, + CreateServerMutationVariables + >(CREATE_ONE_DATABASE_CONNECTION, { + client: apolloMetadataClient ?? ({} as ApolloClient), + }); + + const createOneDatabaseConnection = async ( + input: CreateRemoteServerInput, + ) => { + return await mutate({ + variables: { + input, + }, + awaitRefetchQueries: true, + refetchQueries: [getOperationName(GET_MANY_DATABASE_CONNECTIONS) ?? ''], + }); + }; + + return { + createOneDatabaseConnection, + }; +}; diff --git a/packages/twenty-front/src/modules/databases/hooks/useDeleteOneDatabaseConnection.ts b/packages/twenty-front/src/modules/databases/hooks/useDeleteOneDatabaseConnection.ts new file mode 100644 index 000000000000..eae2f4a42d49 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useDeleteOneDatabaseConnection.ts @@ -0,0 +1,36 @@ +import { ApolloClient, useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; + +import { DELETE_ONE_DATABASE_CONNECTION } from '@/databases/graphql/mutations/deleteOneDatabaseConnection'; +import { GET_MANY_DATABASE_CONNECTIONS } from '@/databases/graphql/queries/findManyDatabaseConnections'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { + DeleteServerMutation, + DeleteServerMutationVariables, + RemoteServerIdInput, +} from '~/generated-metadata/graphql'; + +export const useDeleteOneDatabaseConnection = () => { + const apolloMetadataClient = useApolloMetadataClient(); + + const [mutate] = useMutation< + DeleteServerMutation, + DeleteServerMutationVariables + >(DELETE_ONE_DATABASE_CONNECTION, { + client: apolloMetadataClient ?? ({} as ApolloClient), + }); + + const deleteOneDatabaseConnection = async (input: RemoteServerIdInput) => { + return await mutate({ + variables: { + input, + }, + awaitRefetchQueries: true, + refetchQueries: [getOperationName(GET_MANY_DATABASE_CONNECTIONS) ?? ''], + }); + }; + + return { + deleteOneDatabaseConnection, + }; +}; diff --git a/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnection.ts b/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnection.ts new file mode 100644 index 000000000000..5d13777dfb8a --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnection.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@apollo/client'; + +import { GET_ONE_DATABASE_CONNECTION } from '@/databases/graphql/queries/findOneDatabaseConnection'; +import { getForeignDataWrapperType } from '@/databases/utils/getForeignDataWrapperType'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { + GetOneDatabaseConnectionQuery, + GetOneDatabaseConnectionQueryVariables, +} from '~/generated-metadata/graphql'; + +type UseGetDatabaseConnectionParams = { + databaseKey: string; + connectionId: string; + skip?: boolean; +}; + +export const useGetDatabaseConnection = ({ + databaseKey, + connectionId, + skip, +}: UseGetDatabaseConnectionParams) => { + const apolloMetadataClient = useApolloMetadataClient(); + const foreignDataWrapperType = getForeignDataWrapperType(databaseKey); + + const { data, loading } = useQuery< + GetOneDatabaseConnectionQuery, + GetOneDatabaseConnectionQueryVariables + >(GET_ONE_DATABASE_CONNECTION, { + client: apolloMetadataClient ?? undefined, + skip: skip || !apolloMetadataClient || !foreignDataWrapperType, + variables: { + input: { + id: connectionId, + }, + }, + }); + + const connection = data?.findOneRemoteServerById ?? null; + + return { + connection: + connection?.foreignDataWrapperType === foreignDataWrapperType + ? connection + : null, + loading, + }; +}; diff --git a/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnectionTables.ts b/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnectionTables.ts new file mode 100644 index 000000000000..6548222e65fe --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnectionTables.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@apollo/client'; + +import { GET_MANY_REMOTE_TABLES } from '@/databases/graphql/queries/findManyRemoteTables'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { + GetManyRemoteTablesQuery, + GetManyRemoteTablesQueryVariables, +} from '~/generated-metadata/graphql'; + +type UseGetDatabaseConnectionTablesParams = { + connectionId: string; + skip?: boolean; +}; + +export const useGetDatabaseConnectionTables = ({ + connectionId, + skip, +}: UseGetDatabaseConnectionTablesParams) => { + const apolloMetadataClient = useApolloMetadataClient(); + + const { data } = useQuery< + GetManyRemoteTablesQuery, + GetManyRemoteTablesQueryVariables + >(GET_MANY_REMOTE_TABLES, { + client: apolloMetadataClient ?? undefined, + skip: skip || !apolloMetadataClient, + variables: { + input: { + id: connectionId, + }, + }, + }); + + return { + tables: data?.findAvailableRemoteTablesByServerId || [], + }; +}; diff --git a/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnections.ts b/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnections.ts new file mode 100644 index 000000000000..979249183da7 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useGetDatabaseConnections.ts @@ -0,0 +1,39 @@ +import { useQuery } from '@apollo/client'; + +import { GET_MANY_DATABASE_CONNECTIONS } from '@/databases/graphql/queries/findManyDatabaseConnections'; +import { getForeignDataWrapperType } from '@/databases/utils/getForeignDataWrapperType'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { + GetManyDatabaseConnectionsQuery, + GetManyDatabaseConnectionsQueryVariables, +} from '~/generated-metadata/graphql'; + +type UseGetDatabaseConnectionsParams = { + databaseKey: string; + skip?: boolean; +}; + +export const useGetDatabaseConnections = ({ + databaseKey, + skip, +}: UseGetDatabaseConnectionsParams) => { + const apolloMetadataClient = useApolloMetadataClient(); + const foreignDataWrapperType = getForeignDataWrapperType(databaseKey); + + const { data } = useQuery< + GetManyDatabaseConnectionsQuery, + GetManyDatabaseConnectionsQueryVariables + >(GET_MANY_DATABASE_CONNECTIONS, { + client: apolloMetadataClient ?? undefined, + skip: skip || !apolloMetadataClient || !foreignDataWrapperType, + variables: { + input: { + foreignDataWrapperType: foreignDataWrapperType || '', + }, + }, + }); + + return { + connections: data?.findManyRemoteServersByType || [], + }; +}; diff --git a/packages/twenty-front/src/modules/databases/hooks/useSyncRemoteTable.ts b/packages/twenty-front/src/modules/databases/hooks/useSyncRemoteTable.ts new file mode 100644 index 000000000000..7a3a39ecb7a2 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useSyncRemoteTable.ts @@ -0,0 +1,60 @@ +import { useCallback } from 'react'; +import { ApolloClient, useApolloClient, useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; + +import { SYNC_REMOTE_TABLE } from '@/databases/graphql/mutations/syncRemoteTable'; +import { GET_MANY_REMOTE_TABLES } from '@/databases/graphql/queries/findManyRemoteTables'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; +import { + RemoteTableInput, + SyncRemoteTableMutation, + SyncRemoteTableMutationVariables, +} from '~/generated-metadata/graphql'; + +export const useSyncRemoteTable = () => { + const apolloMetadataClient = useApolloMetadataClient(); + const apolloClient = useApolloClient(); + + const { refetch: refetchObjectMetadataItems } = + useFindManyObjectMetadataItems(); + + const { findManyRecordsQuery: findManyViewsQuery } = useFindManyRecordsQuery({ + objectNameSingular: CoreObjectNameSingular.View, + }); + + const [mutate] = useMutation< + SyncRemoteTableMutation, + SyncRemoteTableMutationVariables + >(SYNC_REMOTE_TABLE, { + client: apolloMetadataClient ?? ({} as ApolloClient), + }); + + const syncRemoteTable = useCallback( + async (input: RemoteTableInput) => { + const remoteTable = await mutate({ + variables: { + input, + }, + awaitRefetchQueries: true, + refetchQueries: [getOperationName(GET_MANY_REMOTE_TABLES) ?? ''], + }); + + // TODO: we should return the tables with the columns and store in cache instead of refetching + await refetchObjectMetadataItems(); + await apolloClient.query({ + query: findManyViewsQuery, + fetchPolicy: 'network-only', + }); + + return remoteTable; + }, + [apolloClient, findManyViewsQuery, mutate, refetchObjectMetadataItems], + ); + + return { + syncRemoteTable, + }; +}; diff --git a/packages/twenty-front/src/modules/databases/hooks/useUnsyncRemoteTable.ts b/packages/twenty-front/src/modules/databases/hooks/useUnsyncRemoteTable.ts new file mode 100644 index 000000000000..6bc85507fe21 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/hooks/useUnsyncRemoteTable.ts @@ -0,0 +1,47 @@ +import { useCallback } from 'react'; +import { ApolloClient, useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; + +import { UNSYNC_REMOTE_TABLE } from '@/databases/graphql/mutations/unsyncRemoteTable'; +import { GET_MANY_REMOTE_TABLES } from '@/databases/graphql/queries/findManyRemoteTables'; +import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems'; +import { + RemoteTableInput, + UnsyncRemoteTableMutation, + UnsyncRemoteTableMutationVariables, +} from '~/generated-metadata/graphql'; + +export const useUnsyncRemoteTable = () => { + const apolloMetadataClient = useApolloMetadataClient(); + const { refetch: refetchObjectMetadataItems } = + useFindManyObjectMetadataItems(); + + const [mutate] = useMutation< + UnsyncRemoteTableMutation, + UnsyncRemoteTableMutationVariables + >(UNSYNC_REMOTE_TABLE, { + client: apolloMetadataClient ?? ({} as ApolloClient), + }); + + const unsyncRemoteTable = useCallback( + async (input: RemoteTableInput) => { + const remoteTable = await mutate({ + variables: { + input, + }, + awaitRefetchQueries: true, + refetchQueries: [getOperationName(GET_MANY_REMOTE_TABLES) ?? ''], + }); + + await refetchObjectMetadataItems(); + + return remoteTable; + }, + [mutate, refetchObjectMetadataItems], + ); + + return { + unsyncRemoteTable, + }; +}; diff --git a/packages/twenty-front/src/modules/databases/utils/getForeignDataWrapperType.ts b/packages/twenty-front/src/modules/databases/utils/getForeignDataWrapperType.ts new file mode 100644 index 000000000000..af50a62419d6 --- /dev/null +++ b/packages/twenty-front/src/modules/databases/utils/getForeignDataWrapperType.ts @@ -0,0 +1,8 @@ +export const getForeignDataWrapperType = (databaseKey: string) => { + switch (databaseKey) { + case 'postgresql': + return 'postgres_fdw'; + default: + return null; + } +}; diff --git a/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx b/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx index f482dbd91f37..73b9a64c615f 100644 --- a/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx +++ b/packages/twenty-front/src/modules/error-handler/components/GenericErrorFallback.tsx @@ -1,7 +1,7 @@ import { FallbackProps } from 'react-error-boundary'; -import { Button } from 'tsup.ui.index'; +import { IconRefresh } from 'twenty-ui'; -import { IconRefresh } from '@/ui/display/icon'; +import { Button } from '@/ui/input/button/components/Button'; import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { AnimatedPlaceholderEmptyContainer, diff --git a/packages/twenty-front/src/modules/error-handler/components/SentryInitiEffect.tsx b/packages/twenty-front/src/modules/error-handler/components/SentryInitiEffect.tsx index cd8160e4f01b..16b1ed014e48 100644 --- a/packages/twenty-front/src/modules/error-handler/components/SentryInitiEffect.tsx +++ b/packages/twenty-front/src/modules/error-handler/components/SentryInitiEffect.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react'; import * as Sentry from '@sentry/react'; +import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { currentUserState } from '@/auth/states/currentUserState'; @@ -7,6 +8,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { sentryConfigState } from '@/client-config/states/sentryConfigState'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; +import { isDefined } from '~/utils/isDefined'; export const SentryInitEffect = () => { const sentryConfig = useRecoilValue(sentryConfigState); @@ -18,8 +20,10 @@ export const SentryInitEffect = () => { const [isSentryInitialized, setIsSentryInitialized] = useState(false); useEffect(() => { - if (sentryConfig?.dsn && !isSentryInitialized) { + if (isNonEmptyString(sentryConfig?.dsn) && !isSentryInitialized) { Sentry.init({ + environment: sentryConfig?.environment ?? undefined, + release: sentryConfig?.release ?? undefined, dsn: sentryConfig?.dsn, integrations: [ new Sentry.BrowserTracing({ @@ -38,7 +42,7 @@ export const SentryInitEffect = () => { setIsSentryInitialized(true); } - if (currentUser) { + if (isDefined(currentUser)) { Sentry.setUser({ email: currentUser?.email, id: currentUser?.id, diff --git a/packages/twenty-front/src/modules/favorites/components/Favorites.tsx b/packages/twenty-front/src/modules/favorites/components/Favorites.tsx index bda4a5db6e32..cd6ee5d8b158 100644 --- a/packages/twenty-front/src/modules/favorites/components/Favorites.tsx +++ b/packages/twenty-front/src/modules/favorites/components/Favorites.tsx @@ -13,6 +13,22 @@ const StyledContainer = styled(NavigationDrawerSection)` width: 100%; `; +const StyledAvatar = styled(Avatar)` + :hover { + cursor: grab; + } +`; + +const StyledNavigationDrawerItem = styled(NavigationDrawerItem)` + :active { + cursor: grabbing; + + .fav-avatar:hover { + cursor: grabbing; + } + } +`; + export const Favorites = () => { const { favorites, handleReorderFavorite } = useFavorites(); @@ -41,15 +57,16 @@ export const Favorites = () => { draggableId={id} index={index} itemComponent={ - ( - )} to={link} diff --git a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts index e616ba797d64..d98c0bbc08bf 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts @@ -84,8 +84,77 @@ export const mocks = [ query: gql` mutation CreateOneFavorite($input: FavoriteCreateInput!) { createFavorite(data: $input) { - id + __typename + id + companyId + createdAt + personId + person { + __typename + xLink { + label + url + } + id + createdAt + city + email + jobTitle + name { + firstName + lastName + } + phone + linkedinLink { + label + url + } + updatedAt + avatarUrl + companyId + } + position + workspaceMemberId + workspaceMember { + __typename + colorScheme + name { + firstName + lastName } + locale + userId + avatarUrl + createdAt + updatedAt + id + } + company { + __typename + xLink { + label + url + } + linkedinLink { + label + url + } + domainName + annualRecurringRevenue { + amountMicros + currencyCode + } + createdAt + address + updatedAt + name + accountOwnerId + employees + id + idealCustomerProfile + } + updatedAt + } } `, variables: { @@ -108,7 +177,7 @@ export const mocks = [ { request: { query: gql` - mutation DeleteOneFavorite($idToDelete: ID!) { + mutation DeleteOneFavorite($idToDelete: UUID!) { deleteFavorite(id: $idToDelete) { id } @@ -128,12 +197,81 @@ export const mocks = [ request: { query: gql` mutation UpdateOneFavorite( - $idToUpdate: ID! + $idToUpdate: UUID! $input: FavoriteUpdateInput! ) { updateFavorite(id: $idToUpdate, data: $input) { - id + __typename + id + companyId + createdAt + personId + person { + __typename + xLink { + label + url + } + id + createdAt + city + email + jobTitle + name { + firstName + lastName + } + phone + linkedinLink { + label + url + } + updatedAt + avatarUrl + companyId + } + position + workspaceMemberId + workspaceMember { + __typename + colorScheme + name { + firstName + lastName } + locale + userId + avatarUrl + createdAt + updatedAt + id + } + company { + __typename + xLink { + label + url + } + linkedinLink { + label + url + } + domainName + annualRecurringRevenue { + amountMicros + currencyCode + } + createdAt + address + updatedAt + name + accountOwnerId + employees + id + idealCustomerProfile + } + updatedAt + } } `, variables: { diff --git a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx index f7fb44d6f525..6a4b83b77d9b 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx +++ b/packages/twenty-front/src/modules/favorites/hooks/__tests__/useFavorites.test.tsx @@ -25,10 +25,6 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => mockId), })); -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: () => () => '\n', -})); - jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ useFindManyRecords: () => ({ records: initialFavorites }), })); @@ -150,7 +146,7 @@ describe('useFavorites', () => { }; const responderProvided: ResponderProvided = { - announce: (message: string) => console.log(message), + announce: () => {}, }; result.current.handleReorderFavorite( diff --git a/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts index 8614bfc60d1c..f01cdd1d4fc2 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/useFavorites.ts @@ -6,38 +6,38 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { Favorite } from '@/favorites/types/Favorite'; import { useGetObjectRecordIdentifierByNameSingular } from '@/object-metadata/hooks/useGetObjectRecordIdentifierByNameSingular'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useFavorites = () => { const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const favoriteObjectNameSingular = 'favorite'; - const { objectMetadataItem: favoriteObjectMetadataItem } = useObjectMetadataItem({ - objectNameSingular: favoriteObjectNameSingular, + objectNameSingular: CoreObjectNameSingular.Favorite, }); const { deleteOneRecord } = useDeleteOneRecord({ - objectNameSingular: favoriteObjectNameSingular, + objectNameSingular: CoreObjectNameSingular.Favorite, }); const { updateOneRecord: updateOneFavorite } = useUpdateOneRecord({ - objectNameSingular: favoriteObjectNameSingular, + objectNameSingular: CoreObjectNameSingular.Favorite, }); const { createOneRecord: createOneFavorite } = useCreateOneRecord({ - objectNameSingular: favoriteObjectNameSingular, + objectNameSingular: CoreObjectNameSingular.Favorite, }); - const { records: favorites } = useFindManyRecords({ - objectNameSingular: favoriteObjectNameSingular, - }); + const { records: favorites } = usePrefetchedData( + PrefetchKey.AllFavorites, + ); const favoriteRelationFieldMetadataItems = useMemo( () => @@ -56,7 +56,7 @@ export const useFavorites = () => { return favorites .map((favorite) => { for (const relationField of favoriteRelationFieldMetadataItems) { - if (isNonNullable(favorite[relationField.name])) { + if (isDefined(favorite[relationField.name])) { const relationObject = favorite[relationField.name]; const relationObjectNameSingular = diff --git a/packages/twenty-front/src/modules/favorites/states/favoritesState.ts b/packages/twenty-front/src/modules/favorites/states/favoritesState.ts index 298de69af83f..95e6c828f1e8 100644 --- a/packages/twenty-front/src/modules/favorites/states/favoritesState.ts +++ b/packages/twenty-front/src/modules/favorites/states/favoritesState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Favorite } from '@/favorites/types/Favorite'; -export const favoritesState = atom({ +export const favoritesState = createState({ key: 'favoritesState', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/favorites/utils/mapFavorites.ts b/packages/twenty-front/src/modules/favorites/utils/mapFavorites.ts index ae9fae903014..c0e5deff1551 100644 --- a/packages/twenty-front/src/modules/favorites/utils/mapFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/utils/mapFavorites.ts @@ -1,10 +1,10 @@ import { getLogoUrlFromDomainName } from '~/utils'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const mapFavorites = (favorites: any) => { return favorites .map((favorite: any) => { - const recordInformation = isNonNullable(favorite?.person) + const recordInformation = isDefined(favorite?.person) ? { id: favorite.person.id, labelIdentifier: @@ -15,7 +15,7 @@ export const mapFavorites = (favorites: any) => { avatarType: 'rounded', link: `/object/person/${favorite.person.id}`, } - : isNonNullable(favorite?.company) + : isDefined(favorite?.company) ? { id: favorite.company.id, labelIdentifier: favorite.company.name, @@ -32,6 +32,6 @@ export const mapFavorites = (favorites: any) => { position: favorite?.position, }; }) - .filter(isNonNullable) + .filter(isDefined) .sort((a: any, b: any) => a.position - b.position); }; diff --git a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuDialog.tsx b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuDialog.tsx index c185d7c8e445..80cc5c6b516f 100644 --- a/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuDialog.tsx +++ b/packages/twenty-front/src/modules/keyboard-shortcut-menu/components/KeyboardShortcutMenuDialog.tsx @@ -1,4 +1,5 @@ -import { IconX } from '@/ui/display/icon'; +import { IconX } from 'twenty-ui'; + import { IconButton } from '@/ui/input/button/components/IconButton'; import { diff --git a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerItems.tsx b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerItems.tsx index 9ac6d6850f35..1147a7d888a3 100644 --- a/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerItems.tsx +++ b/packages/twenty-front/src/modules/navigation/components/MainNavigationDrawerItems.tsx @@ -1,18 +1,11 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useRecoilValue, useSetRecoilState } from 'recoil'; - +import { IconCheckbox, IconInbox, IconSearch, IconSettings, IconMail } from 'twenty-ui'; import { CurrentUserDueTaskCountEffect } from '@/activities/tasks/components/CurrentUserDueTaskCountEffect'; import { currentUserDueTaskCountState } from '@/activities/tasks/states/currentUserTaskCountState'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; import { Favorites } from '@/favorites/components/Favorites'; -import { ObjectMetadataNavItems } from '@/object-metadata/components/ObjectMetadataNavItems'; -import { - IconBell, - IconCheckbox, - IconMail, - IconSearch, - IconSettings, -} from '@/ui/display/icon'; +import { ObjectMetadataNavItems } from '@/object-metadata/components/ObjectMetadataNavItems' import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; import { NavigationDrawerSection } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSection'; import { NavigationDrawerSectionTitle } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle'; @@ -43,9 +36,9 @@ export const MainNavigationDrawerItems = () => { keyboard={['⌘', 'K']} /> ({ +export const currentMobileNavigationDrawerState = createState< + 'main' | 'settings' +>({ key: 'currentMobileNavigationDrawerState', - default: 'main', + defaultValue: 'main', }); diff --git a/packages/twenty-front/src/modules/object-metadata/components/ApolloMetadataClientProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/ApolloMetadataClientProvider.tsx index de90c844b8fc..737811846893 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ApolloMetadataClientProvider.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ApolloMetadataClientProvider.tsx @@ -1,8 +1,4 @@ -import { useMemo } from 'react'; -import { ApolloClient, InMemoryCache } from '@apollo/client'; -import { useRecoilState } from 'recoil'; - -import { tokenPairState } from '@/auth/states/tokenPairState'; +import { useApolloFactory } from '@/apollo/hooks/useApolloFactory'; import { REACT_APP_SERVER_BASE_URL } from '~/config'; import { ApolloMetadataClientContext } from '../context/ApolloClientMetadataContext'; @@ -12,20 +8,10 @@ export const ApolloMetadataClientProvider = ({ }: { children: React.ReactNode; }) => { - const [tokenPair] = useRecoilState(tokenPairState); - const apolloMetadataClient = useMemo(() => { - if (tokenPair?.accessToken.token) { - return new ApolloClient({ - uri: `${REACT_APP_SERVER_BASE_URL}/metadata`, - cache: new InMemoryCache(), - headers: { - Authorization: `Bearer ${tokenPair.accessToken.token}`, - }, - }); - } else { - return null; - } - }, [tokenPair]); + const apolloMetadataClient = useApolloFactory({ + uri: `${REACT_APP_SERVER_BASE_URL}/metadata`, + connectToDevTools: false, + }); return ( diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx index 9288ec017b39..11a60715b43b 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsLoadEffect.tsx @@ -1,13 +1,18 @@ import { useEffect } from 'react'; -import { useRecoilState } from 'recoil'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { currentUserState } from '@/auth/states/currentUserState'; import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const ObjectMetadataItemsLoadEffect = () => { + const currentUser = useRecoilValue(currentUserState); const { objectMetadataItems: newObjectMetadataItems } = - useFindManyObjectMetadataItems(); + useFindManyObjectMetadataItems({ + skip: isUndefinedOrNull(currentUser), + }); const [objectMetadataItems, setObjectMetadataItems] = useRecoilState( objectMetadataItemsState, diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx index b8e1eb3d86f9..0c3ebdadc586 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataItemsProvider.tsx @@ -11,16 +11,14 @@ export const ObjectMetadataItemsProvider = ({ }: React.PropsWithChildren) => { const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const shouldDisplayChildren = () => { - if (objectMetadataItems.length) { - return true; - } - return !currentWorkspaceMember; - }; + + const shouldDisplayChildren = + objectMetadataItems.length > 0 || !currentWorkspaceMember; + return ( <> - {shouldDisplayChildren() && ( + {shouldDisplayChildren && ( {children} diff --git a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx index 765475cfa198..6bcd4c81948f 100644 --- a/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx +++ b/packages/twenty-front/src/modules/object-metadata/components/ObjectMetadataNavItems.tsx @@ -1,37 +1,22 @@ import { useLocation, useNavigate } from 'react-router-dom'; -import { useCachedRootQuery } from '@/apollo/hooks/useCachedRootQuery'; -import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { QueryMethodName } from '@/object-metadata/types/QueryMethodName'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { NavigationDrawerItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerItem'; +import { GraphQLView } from '@/views/types/GraphQLView'; +import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews'; export const ObjectMetadataNavItems = () => { - const { activeObjectMetadataItems, findObjectMetadataItemByNamePlural } = - useObjectMetadataItemForSettings(); + const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); const navigate = useNavigate(); const { getIcon } = useIcons(); const currentPath = useLocation().pathname; - const viewObjectMetadataItem = findObjectMetadataItemByNamePlural('views'); - - const { cachedRootQuery } = useCachedRootQuery({ - objectMetadataItem: viewObjectMetadataItem, - queryMethodName: QueryMethodName.FindMany, - }); - - const { records } = useFindManyRecords({ - skip: cachedRootQuery?.views, - objectNameSingular: CoreObjectNameSingular.View, - useRecordsWithoutConnection: true, - }); - - const views = - records.length > 0 - ? records - : cachedRootQuery?.views?.edges?.map((edge: any) => edge?.node); + const { records: views } = usePrefetchedData( + PrefetchKey.AllViews, + ); return ( <> @@ -63,9 +48,11 @@ export const ObjectMetadataNavItems = () => { : -1; }), ].map((objectMetadataItem) => { - const viewId = views?.find( - (view: any) => view?.objectMetadataId === objectMetadataItem.id, - )?.id; + const objectMetadataViews = getObjectMetadataItemViews( + objectMetadataItem.id, + views, + ); + const viewId = objectMetadataViews[0]?.id; const navigationPath = `/objects/${objectMetadataItem.namePlural}${ viewId ? `?view=${viewId}` : '' diff --git a/packages/twenty-front/src/modules/object-metadata/components/__stories__/ObjectMetadataNavItems.stories.tsx b/packages/twenty-front/src/modules/object-metadata/components/__stories__/ObjectMetadataNavItems.stories.tsx new file mode 100644 index 000000000000..5d97e61db06f --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/components/__stories__/ObjectMetadataNavItems.stories.tsx @@ -0,0 +1,37 @@ +import { expect } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { within } from '@storybook/test'; + +import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; +import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; + +import { ObjectMetadataNavItems } from '../ObjectMetadataNavItems'; + +const meta: Meta = { + title: 'Modules/ObjectMetadata/ObjectMetadataNavItems', + component: ObjectMetadataNavItems, + decorators: [ + ObjectMetadataItemsDecorator, + ComponentWithRouterDecorator, + ComponentWithRecoilScopeDecorator, + SnackBarDecorator, + ], + parameters: { + msw: graphqlMocks, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + play: async () => { + const canvas = within(document.body); + expect(await canvas.findByText('People')).toBeInTheDocument(); + expect(await canvas.findByText('Companies')).toBeInTheDocument(); + expect(await canvas.findByText('Opportunities')).toBeInTheDocument(); + }, +}; diff --git a/packages/twenty-front/src/modules/object-metadata/constants/LabelIdentifierFieldMetadataTypes.ts b/packages/twenty-front/src/modules/object-metadata/constants/LabelIdentifierFieldMetadataTypes.ts new file mode 100644 index 000000000000..fe25319198b2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/constants/LabelIdentifierFieldMetadataTypes.ts @@ -0,0 +1,6 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const LABEL_IDENTIFIER_FIELD_METADATA_TYPES = [ + FieldMetadataType.Number, + FieldMetadataType.Text, +]; diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts index f9ac8b9f56c4..2fce4c3b2a2b 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/mutations.ts @@ -58,7 +58,7 @@ export const CREATE_ONE_RELATION_METADATA_ITEM = gql` export const UPDATE_ONE_FIELD_METADATA_ITEM = gql` mutation UpdateOneFieldMetadataItem( - $idToUpdate: ID! + $idToUpdate: UUID! $updatePayload: UpdateFieldInput! ) { updateOneField(input: { id: $idToUpdate, update: $updatePayload }) { @@ -79,7 +79,7 @@ export const UPDATE_ONE_FIELD_METADATA_ITEM = gql` export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql` mutation UpdateOneObjectMetadataItem( - $idToUpdate: ID! + $idToUpdate: UUID! $updatePayload: UpdateObjectInput! ) { updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) { @@ -102,7 +102,7 @@ export const UPDATE_ONE_OBJECT_METADATA_ITEM = gql` `; export const DELETE_ONE_OBJECT_METADATA_ITEM = gql` - mutation DeleteOneObjectMetadataItem($idToDelete: ID!) { + mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) { deleteOneObject(input: { id: $idToDelete }) { id dataSourceId @@ -123,7 +123,7 @@ export const DELETE_ONE_OBJECT_METADATA_ITEM = gql` `; export const DELETE_ONE_FIELD_METADATA_ITEM = gql` - mutation DeleteOneFieldMetadataItem($idToDelete: ID!) { + mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) { deleteOneField(input: { id: $idToDelete }) { id type diff --git a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts index 1c1b34da6fd1..838c1d143d0b 100644 --- a/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts +++ b/packages/twenty-front/src/modules/object-metadata/graphql/queries.ts @@ -17,6 +17,7 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` description icon isCustom + isRemote isActive isSystem createdAt @@ -64,6 +65,27 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` } defaultValue options + relationDefinition { + direction + sourceObjectMetadata { + id + nameSingular + namePlural + } + sourceFieldMetadata { + id + name + } + targetObjectMetadata { + id + nameSingular + namePlural + } + targetFieldMetadata { + id + name + } + } } } pageInfo { @@ -72,7 +94,6 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` startCursor endCursor } - totalCount } } } @@ -82,7 +103,6 @@ export const FIND_MANY_OBJECT_METADATA_ITEMS = gql` startCursor endCursor } - totalCount } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/ApolloMetadataClientProvider.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/ApolloMetadataClientProvider.tsx index 61a829cc9615..ad2c13a5b71a 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/ApolloMetadataClientProvider.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/ApolloMetadataClientProvider.tsx @@ -1,21 +1,16 @@ import { ReactNode } from 'react'; -import { - ApolloClient, - NormalizedCacheObject, - useApolloClient, -} from '@apollo/client'; import { ApolloMetadataClientContext } from '@/object-metadata/context/ApolloClientMetadataContext'; +import { mockedMetadataApolloClient } from '~/testing/mockedMetadataApolloClient'; -export const TestApolloMetadataClientProvider = ({ +export const ApolloMetadataClientMockedProvider = ({ children, }: { children: ReactNode; }) => { - const client = useApolloClient() as ApolloClient; return ( - - {client ? children : ''} + + {mockedMetadataApolloClient ? children : ''} ); }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts new file mode 100644 index 000000000000..61df6cf56869 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectMetadataItem.ts @@ -0,0 +1,76 @@ +import { gql } from '@apollo/client'; + +export const query = gql` + mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) { + createOneObject(input: $input) { + id + dataSourceId + nameSingular + namePlural + labelSingular + labelPlural + description + icon + isCustom + isActive + createdAt + updatedAt + labelIdentifierFieldMetadataId + imageIdentifierFieldMetadataId + } + } +`; + +export const findManyViewsQuery = gql` + query FindManyViews($filter: ViewFilterInput, $orderBy: ViewOrderByInput, $lastCursor: String, $limit: Float) { + views(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor) { + edges { + node { + __typename + id + objectMetadataId + type + createdAt + name + updatedAt + } + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } +`; + +export const variables = { + input: { + object: { + icon: 'IconPlus', + labelPlural: 'View Filters', + labelSingular: 'View Filter', + nameSingular: 'viewFilter', + namePlural: 'viewFilters', + }, + }, +}; + +export const responseData = { + id: '', + dataSourceId: '', + nameSingular: 'viewFilter', + namePlural: 'viewFilters', + labelSingular: 'View Filter', + labelPlural: 'View Filters', + description: '', + icon: '', + isCustom: false, + isActive: true, + createdAt: '', + updatedAt: '', + labelIdentifierFieldMetadataId: '', + imageIdentifierFieldMetadataId: '', +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectRecordMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectRecordMetadataItem.ts deleted file mode 100644 index 37adb3417a88..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useCreateOneObjectRecordMetadataItem.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { gql } from '@apollo/client'; - -export const query = gql` - mutation CreateOneObjectMetadataItem($input: CreateOneObjectInput!) { - createOneObject(input: $input) { - id - dataSourceId - nameSingular - namePlural - labelSingular - labelPlural - description - icon - isCustom - isActive - createdAt - updatedAt - labelIdentifierFieldMetadataId - imageIdentifierFieldMetadataId - } - } -`; - -export const variables = { - input: { - object: { - labelPlural: 'View Filters', - labelSingular: 'View Filter', - nameSingular: 'viewFilter', - namePlural: 'viewFilters', - }, - }, -}; - -export const responseData = { - id: '', - dataSourceId: '', - nameSingular: 'viewFilter', - namePlural: 'viewFilters', - labelSingular: 'View Filter', - labelPlural: 'View Filters', - description: '', - icon: '', - isCustom: false, - isActive: true, - createdAt: '', - updatedAt: '', - labelIdentifierFieldMetadataId: '', - imageIdentifierFieldMetadataId: '', -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts index 42ca0a9952f8..e7be9105bf9f 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useDeleteOneObjectMetadataItem.ts @@ -1,7 +1,7 @@ import { gql } from '@apollo/client'; export const query = gql` - mutation DeleteOneObjectMetadataItem($idToDelete: ID!) { + mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) { deleteOneObject(input: { id: $idToDelete }) { id dataSourceId diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts index 66a29db3f7cd..14302051e09e 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFieldMetadataItem.ts @@ -16,7 +16,7 @@ const baseFields = ` export const queries = { eraseMetadataField: gql` - mutation DeleteOneFieldMetadataItem($idToDelete: ID!) { + mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) { deleteOneField(input: { id: $idToDelete }) { ${baseFields} } @@ -24,7 +24,7 @@ export const queries = { `, activateMetadataField: gql` mutation UpdateOneFieldMetadataItem( - $idToUpdate: ID! + $idToUpdate: UUID! $updatePayload: UpdateFieldInput! ) { updateOneField(input: { id: $idToUpdate, update: $updatePayload }) { diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts new file mode 100644 index 000000000000..fa99fc04c4d3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems.ts @@ -0,0 +1,55 @@ +import { gql } from '@apollo/client'; + +export const query = gql` + mutation UpdateOneObjectMetadataItem( + $idToUpdate: UUID! + $updatePayload: UpdateObjectInput! + ) { + updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) { + id + dataSourceId + nameSingular + namePlural + labelSingular + labelPlural + description + icon + isCustom + isActive + createdAt + updatedAt + labelIdentifierFieldMetadataId + imageIdentifierFieldMetadataId + } + } +`; + +export const variables = { + idToUpdate: 'idToUpdate', + updatePayload: { + description: 'newDescription', + icon: undefined, + labelIdentifierFieldMetadataId: null, + labelPlural: 'labelPlural', + labelSingular: 'labelSingular', + namePlural: 'labelPlural', + nameSingular: 'labelSingular', + }, +}; + +export const responseData = { + id: 'idToUpdate', + dataSourceId: 'dataSourceId', + nameSingular: 'nameSingular', + namePlural: 'namePlural', + labelSingular: 'labelSingular', + labelPlural: 'labelPlural', + description: 'newDescription', + icon: '', + isCustom: false, + isActive: true, + createdAt: '', + updatedAt: '', + labelIdentifierFieldMetadataId: '', + imageIdentifierFieldMetadataId: '', +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFindManyObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFindManyObjectMetadataItems.ts index e24af05de4a0..951b2742fbef 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFindManyObjectMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useFindManyObjectMetadataItems.ts @@ -17,6 +17,7 @@ export const query = gql` description icon isCustom + isRemote isActive isSystem createdAt @@ -72,7 +73,6 @@ export const query = gql` startCursor endCursor } - totalCount } } } @@ -82,7 +82,6 @@ export const query = gql` startCursor endCursor } - totalCount } } `; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useObjectMetadataItemForSettings.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useObjectMetadataItemForSettings.ts deleted file mode 100644 index a9b8aadea751..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__mocks__/useObjectMetadataItemForSettings.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { gql } from '@apollo/client'; - -export const query = gql` - mutation UpdateOneObjectMetadataItem( - $idToUpdate: ID! - $updatePayload: UpdateObjectInput! - ) { - updateOneObject(input: { id: $idToUpdate, update: $updatePayload }) { - id - dataSourceId - nameSingular - namePlural - labelSingular - labelPlural - description - icon - isCustom - isActive - createdAt - updatedAt - labelIdentifierFieldMetadataId - imageIdentifierFieldMetadataId - } - } -`; - -export const variables = { - idToUpdate: 'idToUpdate', - updatePayload: { - description: 'newDescription', - icon: undefined, - labelIdentifierFieldMetadataId: null, - labelPlural: 'labelPlural', - labelSingular: 'labelSingular', - namePlural: 'labelPlural', - nameSingular: 'labelSingular', - }, -}; - -export const responseData = { - id: 'idToUpdate', - dataSourceId: 'dataSourceId', - nameSingular: 'nameSingular', - namePlural: 'namePlural', - labelSingular: 'labelSingular', - labelPlural: 'labelPlural', - description: 'newDescription', - icon: '', - isCustom: false, - isActive: true, - createdAt: '', - updatedAt: '', - labelIdentifierFieldMetadataId: '', - imageIdentifierFieldMetadataId: '', -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx new file mode 100644 index 000000000000..03443712d9de --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectMetadataItem.test.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useCreateOneObjectMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem'; + +import { + findManyViewsQuery, + query, + responseData, + variables, +} from '../__mocks__/useCreateOneObjectMetadataItem'; + +const mocks = [ + { + request: { + query, + variables, + }, + result: jest.fn(() => ({ + data: { + createOneObject: responseData, + }, + })), + }, + { + request: { + query: findManyViewsQuery, + variables: {}, + }, + result: jest.fn(() => ({ + data: { + views: { + __typename: 'ViewConnection', + totalCount: 0, + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + startCursor: '', + endCursor: '', + }, + edges: [], + }, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +describe('useCreateOneObjectMetadataItem', () => { + it('should work as expected', async () => { + const { result } = renderHook(() => useCreateOneObjectMetadataItem(), { + wrapper: Wrapper, + }); + + await act(async () => { + const res = await result.current.createOneObjectMetadataItem({ + icon: 'IconPlus', + labelPlural: 'View Filters', + labelSingular: 'View Filter', + namePlural: 'viewFilters', + nameSingular: 'viewFilter', + }); + + expect(res.data).toEqual({ createOneObject: responseData }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectRecordMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectRecordMetadataItem.test.tsx deleted file mode 100644 index 588e6d9a04c2..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneObjectRecordMetadataItem.test.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useCreateOneObjectRecordMetadataItem } from '@/object-metadata/hooks/useCreateOneObjectMetadataItem'; - -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; -import { - query, - responseData, - variables, -} from '../__mocks__/useCreateOneObjectRecordMetadataItem'; - -const mocks = [ - { - request: { - query, - variables, - }, - result: jest.fn(() => ({ - data: { - createOneObject: responseData, - }, - })), - }, -]; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -describe('useCreateOneObjectRecordMetadataItem', () => { - it('should work as expected', async () => { - const { result } = renderHook( - () => useCreateOneObjectRecordMetadataItem(), - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - const res = await result.current.createOneObjectMetadataItem({ - labelPlural: 'View Filters', - labelSingular: 'View Filter', - nameSingular: 'viewFilter', - namePlural: 'viewFilters', - }); - - expect(res.data).toEqual({ createOneObject: responseData }); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneRelationMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneRelationMetadataItem.test.tsx index 46e41021a0eb..2d9b7f3e3f01 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneRelationMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useCreateOneRelationMetadataItem.test.tsx @@ -6,7 +6,6 @@ import { RecoilRoot } from 'recoil'; import { useCreateOneRelationMetadataItem } from '@/object-metadata/hooks/useCreateOneRelationMetadataItem'; import { RelationMetadataType } from '~/generated/graphql'; -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; import { query, responseData, @@ -30,9 +29,7 @@ const mocks = [ const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useDeleteOneObjectMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useDeleteOneObjectMetadataItem.test.tsx index f4e6a96d6a39..03954953f7ab 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useDeleteOneObjectMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useDeleteOneObjectMetadataItem.test.tsx @@ -5,7 +5,6 @@ import { RecoilRoot } from 'recoil'; import { useDeleteOneObjectMetadataItem } from '@/object-metadata/hooks/useDeleteOneObjectMetadataItem'; -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; import { query, responseData, @@ -29,9 +28,7 @@ const mocks = [ const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx index 7527677e7a12..d35e7bb602ae 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFieldMetadataItem.test.tsx @@ -7,7 +7,6 @@ import { useFieldMetadataItem } from '@/object-metadata/hooks/useFieldMetadataIt import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataType } from '~/generated/graphql'; -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; import { objectMetadataId, queries, @@ -85,9 +84,7 @@ const mocks = [ const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - + {children} ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFilteredObjectMetadataItems.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFilteredObjectMetadataItems.test.tsx new file mode 100644 index 000000000000..f745f257670f --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFilteredObjectMetadataItems.test.tsx @@ -0,0 +1,102 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot, useSetRecoilState } from 'recoil'; + +import { + query, + responseData, + variables, +} from '@/object-metadata/hooks/__mocks__/useFilteredObjectMetadataItems'; +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; + +const mocks = [ + { + request: { + query, + variables, + }, + result: jest.fn(() => ({ + data: { + updateOneObject: responseData, + }, + })), + }, +]; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +describe('useFilteredObjectMetadataItems', () => { + it('should findActiveObjectMetadataItemBySlug', async () => { + const { result } = renderHook( + () => { + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + setMetadataItems(mockObjectMetadataItems); + + return useFilteredObjectMetadataItems(); + }, + { + wrapper: Wrapper, + }, + ); + + act(() => { + const res = result.current.findActiveObjectMetadataItemBySlug('people'); + expect(res).toBeDefined(); + expect(res?.namePlural).toBe('people'); + }); + }); + + it('should findObjectMetadataItemById', async () => { + const { result } = renderHook( + () => { + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + setMetadataItems(mockObjectMetadataItems); + + return useFilteredObjectMetadataItems(); + }, + { + wrapper: Wrapper, + }, + ); + + act(() => { + const res = result.current.findObjectMetadataItemById( + '20202020-480c-434e-b4c7-e22408b97047', + ); + expect(res).toBeDefined(); + expect(res?.namePlural).toBe('companies'); + }); + }); + + it('should findObjectMetadataItemByNamePlural', async () => { + const { result } = renderHook( + () => { + const setMetadataItems = useSetRecoilState(objectMetadataItemsState); + setMetadataItems(mockObjectMetadataItems); + + return useFilteredObjectMetadataItems(); + }, + { + wrapper: Wrapper, + }, + ); + + act(() => { + const res = + result.current.findObjectMetadataItemByNamePlural('opportunities'); + expect(res).toBeDefined(); + expect(res?.namePlural).toBe('opportunities'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFindManyObjectMetadataItems.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFindManyObjectMetadataItems.test.tsx index 44967e53c3f8..acb7d7ce439a 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFindManyObjectMetadataItems.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useFindManyObjectMetadataItems.test.tsx @@ -6,7 +6,6 @@ import { RecoilRoot } from 'recoil'; import { useFindManyObjectMetadataItems } from '@/object-metadata/hooks/useFindManyObjectMetadataItems'; import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; import { query, responseData, @@ -30,11 +29,9 @@ const mocks = [ const Wrapper = ({ children }: { children: ReactNode }) => ( - - - {children} - - + + {children} + ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectOrderByField.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectOrderByField.test.ts deleted file mode 100644 index c71a90358fb1..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectOrderByField.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { renderHook } from '@testing-library/react'; - -import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOrderByField'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -const mockObjectMetadataItems = getObjectMetadataItemsMock(); - -describe('useGetObjectOrderByField', () => { - it('should work as expected', () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - - const { result } = renderHook(() => - useGetObjectOrderByField({ objectMetadataItem })('AscNullsLast'), - ); - expect(result.current).toEqual({ - name: { firstName: 'AscNullsLast', lastName: 'AscNullsLast' }, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectOrderByField.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectOrderByField.test.tsx new file mode 100644 index 000000000000..646806c9762a --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetObjectOrderByField.test.tsx @@ -0,0 +1,33 @@ +import { ReactNode } from 'react'; +import { MockedProvider } from '@apollo/client/testing'; +import { renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; + +import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOrderByField'; + +const Wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +describe('useGetObjectOrderByField', () => { + it('should work as expected', () => { + const { result } = renderHook( + () => { + const { getObjectOrderByField } = useGetObjectOrderByField({ + objectNameSingular: 'person', + }); + + return getObjectOrderByField('AscNullsLast'); + }, + { + wrapper: Wrapper, + }, + ); + + expect(result.current).toEqual({ + name: { firstName: 'AscNullsLast', lastName: 'AscNullsLast' }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx index b13554ed6e14..55c7a7f09efc 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useGetRelationMetadata.test.tsx @@ -7,15 +7,9 @@ import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMe import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; - const Wrapper = ({ children }: { children: ReactNode }) => ( - - - {children} - - + {children} ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapFieldMetadataToGraphQLQuery.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapFieldMetadataToGraphQLQuery.test.tsx deleted file mode 100644 index 0425726a6e60..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapFieldMetadataToGraphQLQuery.test.tsx +++ /dev/null @@ -1,250 +0,0 @@ -import { renderHook } from '@testing-library/react'; -import { RecoilRoot, useSetRecoilState } from 'recoil'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { RelationMetadataType } from '~/generated/graphql'; - -const mockObjectMetadataItems = getObjectMetadataItemsMock(); - -const formatGQLString = (inputString: string) => - inputString.replace(/^\s*[\r\n]/gm, ''); - -const getOneToManyRelation = () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'opportunity', - )!; - - return { - field: objectMetadataItem.fields.find((field) => field.name === 'company')!, - res: `company - { - __typename - id - xLink - { - label - url - } -accountOwner - { - __typename - id - } - linkedinLink - { - label - url - } -attachments - { - edges { - node { - __typename - id - } - } - } -domainName -opportunities - { - edges { - node { - __typename - id - } - } - } - annualRecurringRevenue - { - amountMicros - currencyCode - } -createdAt -address -updatedAt -activityTargets - { - edges { - node { - __typename - id - } - } - } -favorites - { - edges { - node { - __typename - id - } - } - } -people - { - edges { - node { - __typename - id - } - } - } -name -accountOwnerId -employees -id -idealCustomerProfile - }`, - }; -}; - -const getOneToOneRelationField = () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'opportunity', - )!; - - const oneToManyfield = objectMetadataItem.fields.find( - (field) => field.name === 'company', - )!; - - const field: FieldMetadataItem = { - ...oneToManyfield, - toRelationMetadata: { - ...oneToManyfield.toRelationMetadata!, - relationType: RelationMetadataType.OneToOne, - }, - }; - - return field; -}; - -const getOneToManyFromRelationField = () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - - const field = objectMetadataItem.fields.find( - (field) => field.name === 'opportunities', - )!; - - return { - field, - res: `opportunities - { - edges { - node { - __typename - id - personId -pointOfContactId -updatedAt -company - { - __typename - id - } -companyId -pipelineStepId -probability -pipelineStep - { - __typename - id - } -closeDate - amount - { - amountMicros - currencyCode - } -id -createdAt -pointOfContact - { - __typename - id - } -person - { - __typename - id - } - } - } - }`, - }; -}; - -const getFullNameRelation = () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - - const field = objectMetadataItem.fields.find( - (field) => field.name === 'name', - )!; - - return { - field, - res: `\n name\n {\n firstName\n lastName\n }\n `, - }; -}; - -describe('useMapFieldMetadataToGraphQLQuery', () => { - it('should work as expected', async () => { - const { result } = renderHook( - () => { - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(mockObjectMetadataItems); - - return { - mapFieldMetadataToGraphQLQuery: useMapFieldMetadataToGraphQLQuery(), - }; - }, - { - wrapper: RecoilRoot, - }, - ); - - const oneToManyRelation = getOneToManyRelation(); - - const { mapFieldMetadataToGraphQLQuery } = result.current; - - const oneToManyRelationFieldRes = mapFieldMetadataToGraphQLQuery({ - field: oneToManyRelation.field, - }); - - expect(formatGQLString(oneToManyRelationFieldRes)).toEqual( - oneToManyRelation.res, - ); - - const oneToOneRelation = getOneToOneRelationField(); - - const oneToOneRelationFieldRes = mapFieldMetadataToGraphQLQuery({ - field: oneToOneRelation, - }); - - expect(formatGQLString(oneToOneRelationFieldRes)).toEqual( - oneToManyRelation.res, - ); - - const oneToManyFromRelation = getOneToManyFromRelationField(); - const oneToManyFromRelationFieldRes = mapFieldMetadataToGraphQLQuery({ - field: oneToManyFromRelation.field, - }); - - expect(formatGQLString(oneToManyFromRelationFieldRes)).toEqual( - oneToManyFromRelation.res, - ); - - const fullNameRelation = getFullNameRelation(); - const fullNameFieldRes = mapFieldMetadataToGraphQLQuery({ - field: fullNameRelation.field, - }); - - expect(fullNameFieldRes).toEqual(fullNameRelation.res); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapToObjectRecordIdentifier.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapToObjectRecordIdentifier.test.tsx index 11fb35d6e7cf..17337fd905f6 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapToObjectRecordIdentifier.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useMapToObjectRecordIdentifier.test.tsx @@ -2,21 +2,19 @@ import { renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -const mockObjectMetadataItems = getObjectMetadataItemsMock(); describe('useMapToObjectRecordIdentifier', () => { it('should work as expected', async () => { const { result } = renderHook( () => { - const objectMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; + const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ + objectNameSingular: 'person', + }); - return useMapToObjectRecordIdentifier({ - objectMetadataItem, - })({ id: 'id', name: { firstName: 'Sheldon', lastName: 'Cooper' } }); + return mapToObjectRecordIdentifier({ + id: 'id', + name: { firstName: 'Sheldon', lastName: 'Cooper' }, + }); }, { wrapper: RecoilRoot, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx index e8d124ce8e36..7c232e600e3d 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItem.test.tsx @@ -5,18 +5,13 @@ import { RecoilRoot } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; - const Wrapper = ({ children }: { children: ReactNode }) => ( - - - {children} - - + {children} ); +// Split into tests for each new hook describe('useObjectMetadataItem', () => { it('should return correct properties', async () => { const { result } = renderHook( @@ -26,41 +21,8 @@ describe('useObjectMetadataItem', () => { }, ); - const { - basePathToShowPage, - objectMetadataItem, - labelIdentifierFieldMetadata, - getRecordFromCache, - findManyRecordsQuery, - modifyRecordFromCache, - findOneRecordQuery, - createOneRecordMutation, - updateOneRecordMutation, - deleteOneRecordMutation, - executeQuickActionOnOneRecordMutation, - createManyRecordsMutation, - deleteManyRecordsMutation, - mapToObjectRecordIdentifier, - getObjectOrderByField, - } = result.current; + const { objectMetadataItem } = result.current; - expect(labelIdentifierFieldMetadata).toBeUndefined(); - expect(basePathToShowPage).toBe('/object/opportunity/'); expect(objectMetadataItem.id).toBe('20202020-cae9-4ff4-9579-f7d9fe44c937'); - expect(typeof getRecordFromCache).toBe('function'); - expect(typeof modifyRecordFromCache).toBe('function'); - expect(typeof mapToObjectRecordIdentifier).toBe('function'); - expect(typeof getObjectOrderByField).toBe('function'); - expect(findManyRecordsQuery).toHaveProperty('kind', 'Document'); - expect(findOneRecordQuery).toHaveProperty('kind', 'Document'); - expect(createOneRecordMutation).toHaveProperty('kind', 'Document'); - expect(updateOneRecordMutation).toHaveProperty('kind', 'Document'); - expect(deleteOneRecordMutation).toHaveProperty('kind', 'Document'); - expect(executeQuickActionOnOneRecordMutation).toHaveProperty( - 'kind', - 'Document', - ); - expect(createManyRecordsMutation).toHaveProperty('kind', 'Document'); - expect(deleteManyRecordsMutation).toHaveProperty('kind', 'Document'); }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItemForSettings.test.tsx b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItemForSettings.test.tsx deleted file mode 100644 index dd382ebcb6ba..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useObjectMetadataItemForSettings.test.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot, useSetRecoilState } from 'recoil'; - -import { - query, - responseData, - variables, -} from '@/object-metadata/hooks/__mocks__/useObjectMetadataItemForSettings'; -import { useObjectMetadataItemForSettings } from '@/object-metadata/hooks/useObjectMetadataItemForSettings'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; - -import { TestApolloMetadataClientProvider } from '../__mocks__/ApolloMetadataClientProvider'; - -const mocks = [ - { - request: { - query, - variables, - }, - result: jest.fn(() => ({ - data: { - updateOneObject: responseData, - }, - })), - }, -]; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -const mockObjectMetadataItems = getObjectMetadataItemsMock(); - -describe('useObjectMetadataItemForSettings', () => { - it('should findActiveObjectMetadataItemBySlug', async () => { - const { result } = renderHook( - () => { - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(mockObjectMetadataItems); - - return useObjectMetadataItemForSettings(); - }, - { - wrapper: Wrapper, - }, - ); - - act(() => { - const res = result.current.findActiveObjectMetadataItemBySlug('people'); - expect(res).toBeDefined(); - expect(res?.namePlural).toBe('people'); - }); - }); - - it('should findObjectMetadataItemById', async () => { - const { result } = renderHook( - () => { - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(mockObjectMetadataItems); - - return useObjectMetadataItemForSettings(); - }, - { - wrapper: Wrapper, - }, - ); - - act(() => { - const res = result.current.findObjectMetadataItemById( - '20202020-480c-434e-b4c7-e22408b97047', - ); - expect(res).toBeDefined(); - expect(res?.namePlural).toBe('companies'); - }); - }); - - it('should findObjectMetadataItemByNamePlural', async () => { - const { result } = renderHook( - () => { - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(mockObjectMetadataItems); - - return useObjectMetadataItemForSettings(); - }, - { - wrapper: Wrapper, - }, - ); - - act(() => { - const res = - result.current.findObjectMetadataItemByNamePlural('opportunities'); - expect(res).toBeDefined(); - expect(res?.namePlural).toBe('opportunities'); - }); - }); - - it('should editObjectMetadataItem', async () => { - const { result } = renderHook( - () => { - const setMetadataItems = useSetRecoilState(objectMetadataItemsState); - setMetadataItems(mockObjectMetadataItems); - - return useObjectMetadataItemForSettings(); - }, - { - wrapper: Wrapper, - }, - ); - - await act(async () => { - const res = await result.current.editObjectMetadataItem({ - id: 'idToUpdate', - description: 'newDescription', - labelPlural: 'labelPlural', - labelSingular: 'labelSingular', - }); - expect(res.data).toEqual({ updateOneObject: responseData }); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useApolloMetadataClient.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useApolloMetadataClient.ts index 01a262e0287d..5618138ae89f 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useApolloMetadataClient.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useApolloMetadataClient.ts @@ -1,9 +1,15 @@ import { useContext } from 'react'; +import { useApolloClient } from '@apollo/client'; import { ApolloMetadataClientContext } from '../context/ApolloClientMetadataContext'; export const useApolloMetadataClient = () => { const apolloMetadataClient = useContext(ApolloMetadataClientContext); + const apolloClient = useApolloClient(); + + if (process.env.NODE_ENV === 'test') { + return apolloClient; + } return apolloMetadataClient; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts index 5f516def5197..f673adee7ea4 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts @@ -23,6 +23,14 @@ export const useColumnDefinitionsFromFieldMetadata = ( [objectMetadataItem], ); + const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ + fields: activeFieldMetadataItems, + }); + + const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({ + fields: activeFieldMetadataItems, + }); + const columnDefinitions: ColumnDefinition[] = useMemo( () => objectMetadataItem @@ -35,17 +43,29 @@ export const useColumnDefinitionsFromFieldMetadata = ( }), ) .filter(filterAvailableTableColumns) - : [], - [activeFieldMetadataItems, objectMetadataItem], - ); + .map((column) => { + const existsInFilterDefinitions = filterDefinitions.some( + (filter) => filter.fieldMetadataId === column.fieldMetadataId, + ); - const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ - fields: activeFieldMetadataItems, - }); + const existsInSortDefinitions = sortDefinitions.some( + (sort) => sort.fieldMetadataId === column.fieldMetadataId, + ); - const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({ - fields: activeFieldMetadataItems, - }); + return { + ...column, + isFilterable: existsInFilterDefinitions, + isSortable: existsInSortDefinitions, + }; + }) + : [], + [ + activeFieldMetadataItems, + objectMetadataItem, + filterDefinitions, + sortDefinitions, + ], + ); return { columnDefinitions, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneFieldMetadataItem.ts index e6a0dd894812..d272404c7df0 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneFieldMetadataItem.ts @@ -1,11 +1,10 @@ import { ApolloClient, useMutation } from '@apollo/client'; import { getOperationName } from '@apollo/client/utilities'; -import { FieldType } from '@/object-record/record-field/types/FieldType'; import { + CreateFieldInput, CreateOneFieldMetadataItemMutation, CreateOneFieldMetadataItemMutationVariables, - FieldMetadataType, } from '~/generated-metadata/graphql'; import { CREATE_ONE_FIELD_METADATA_ITEM } from '../graphql/mutations'; @@ -13,13 +12,6 @@ import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries'; import { useApolloMetadataClient } from './useApolloMetadataClient'; -type CreateOneFieldMetadataItemArgs = Omit< - CreateOneFieldMetadataItemMutationVariables['input']['field'], - 'type' -> & { - type: FieldType; -}; - export const useCreateOneFieldMetadataItem = () => { const apolloMetadataClient = useApolloMetadataClient(); @@ -30,16 +22,11 @@ export const useCreateOneFieldMetadataItem = () => { client: apolloMetadataClient ?? ({} as ApolloClient), }); - const createOneFieldMetadataItem = async ( - input: CreateOneFieldMetadataItemArgs, - ) => { + const createOneFieldMetadataItem = async (input: CreateFieldInput) => { return await mutate({ variables: { input: { - field: { - ...input, - type: input.type as FieldMetadataType, // Todo improve typing once we have aligned backend and frontend - }, + field: input, }, }, awaitRefetchQueries: true, diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts index e3794488100f..19df20de7c30 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useCreateOneObjectMetadataItem.ts @@ -1,7 +1,10 @@ -import { ApolloClient, useMutation } from '@apollo/client'; +import { ApolloClient, useApolloClient, useMutation } from '@apollo/client'; import { getOperationName } from '@apollo/client/utilities'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; import { + CreateObjectInput, CreateOneObjectMetadataItemMutation, CreateOneObjectMetadataItemMutationVariables, } from '~/generated-metadata/graphql'; @@ -11,8 +14,13 @@ import { FIND_MANY_OBJECT_METADATA_ITEMS } from '../graphql/queries'; import { useApolloMetadataClient } from './useApolloMetadataClient'; -export const useCreateOneObjectRecordMetadataItem = () => { +export const useCreateOneObjectMetadataItem = () => { const apolloMetadataClient = useApolloMetadataClient(); + const apolloClient = useApolloClient(); + + const { findManyRecordsQuery } = useFindManyRecordsQuery({ + objectNameSingular: CoreObjectNameSingular.View, + }); const [mutate] = useMutation< CreateOneObjectMetadataItemMutation, @@ -21,20 +29,21 @@ export const useCreateOneObjectRecordMetadataItem = () => { client: apolloMetadataClient ?? ({} as ApolloClient), }); - const createOneObjectMetadataItem = async ( - input: CreateOneObjectMetadataItemMutationVariables['input']['object'], - ) => { - return await mutate({ + const createOneObjectMetadataItem = async (input: CreateObjectInput) => { + const createdObjectMetadata = await mutate({ variables: { - input: { - object: { - ...input, - }, - }, + input: { object: input }, }, awaitRefetchQueries: true, refetchQueries: [getOperationName(FIND_MANY_OBJECT_METADATA_ITEMS) ?? ''], }); + + await apolloClient.query({ + query: findManyRecordsQuery, + fetchPolicy: 'network-only', + }); + + return createdObjectMetadata; }; return { diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts index d2606ad926b5..51c9000999b4 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFieldMetadataItem.ts @@ -1,11 +1,10 @@ import { v4 } from 'uuid'; -import { FieldType } from '@/object-record/record-field/types/FieldType'; +import { FieldMetadataOption } from '@/object-metadata/types/FieldMetadataOption.ts'; +import { getDefaultValueForBackend } from '@/object-metadata/utils/getDefaultValueForBackend'; import { Field } from '~/generated/graphql'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataItem } from '../types/FieldMetadataItem'; -import { FieldMetadataOption } from '../types/FieldMetadataOption'; import { formatFieldMetadataItemInput } from '../utils/formatFieldMetadataItemInput'; import { useCreateOneFieldMetadataItem } from './useCreateOneFieldMetadataItem'; @@ -18,36 +17,60 @@ export const useFieldMetadataItem = () => { const { deleteOneFieldMetadataItem } = useDeleteOneFieldMetadataItem(); const createMetadataField = ( - input: Pick & { - defaultValue?: unknown; + input: Pick< + Field, + 'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options' + > & { objectMetadataId: string; - options?: Omit[]; - type: FieldMetadataType; }, - ) => - createOneFieldMetadataItem({ - ...formatFieldMetadataItemInput(input), - defaultValue: input.defaultValue, + ) => { + const formattedInput = formatFieldMetadataItemInput(input); + + const defaultValue = getDefaultValueForBackend( + input.defaultValue ?? formattedInput.defaultValue, + input.type, + ); + + return createOneFieldMetadataItem({ + ...formattedInput, + defaultValue, objectMetadataId: input.objectMetadataId, - type: input.type as FieldType, + type: input.type, }); + }; const editMetadataField = ( - input: Pick & { - options?: FieldMetadataOption[]; - }, - ) => - updateOneFieldMetadataItem({ + input: Pick< + Field, + | 'id' + | 'label' + | 'icon' + | 'description' + | 'defaultValue' + | 'type' + | 'options' + >, + ) => { + const formattedInput = formatFieldMetadataItemInput(input); + const defaultValue = input.defaultValue + ? typeof input.defaultValue == 'string' + ? `'${input.defaultValue}'` + : input.defaultValue + : formattedInput.defaultValue ?? undefined; + + return updateOneFieldMetadataItem({ fieldMetadataIdToUpdate: input.id, updatePayload: formatFieldMetadataItemInput({ ...input, + defaultValue, // In Edit mode, all options need an id, // so we generate an id for newly created options. - options: input.options?.map((option) => + options: input.options?.map((option: FieldMetadataOption) => option.id ? option : { ...option, id: v4() }, ), }), }); + }; const activateMetadataField = (metadataField: FieldMetadataItem) => updateOneFieldMetadataItem({ diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts index 75ad0aed1c53..8241aeffb1f1 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFilterOutUnexistingObjectMetadataItems.ts @@ -2,7 +2,7 @@ import { useRecoilValue } from 'recoil'; import { objectMetadataItemsByNameSingularMapSelector } from '@/object-metadata/states/objectMetadataItemsByNameSingularMapSelector'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useFilterOutUnexistingObjectMetadataItems = () => { const objectMetadataItemsByNameSingularMap = useRecoilValue( @@ -12,7 +12,7 @@ export const useFilterOutUnexistingObjectMetadataItems = () => { const filterOutUnexistingObjectMetadataItems = ( objectMetadatItem: ObjectMetadataItem, ) => - isNonNullable( + isDefined( objectMetadataItemsByNameSingularMap.get(objectMetadatItem.nameSingular), ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts new file mode 100644 index 000000000000..2ac05fa4f9d3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFilteredObjectMetadataItems.ts @@ -0,0 +1,41 @@ +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; + +import { getObjectSlug } from '../utils/getObjectSlug'; + +export const useFilteredObjectMetadataItems = () => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const activeObjectMetadataItems = objectMetadataItems.filter( + ({ isActive, isSystem }) => isActive && !isSystem, + ); + const inactiveObjectMetadataItems = objectMetadataItems.filter( + ({ isActive, isSystem }) => !isActive && !isSystem, + ); + + const findActiveObjectMetadataItemBySlug = (slug: string) => + activeObjectMetadataItems.find( + (activeObjectMetadataItem) => + getObjectSlug(activeObjectMetadataItem) === slug, + ); + + const findObjectMetadataItemById = (id: string) => + objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.id === id, + ); + + const findObjectMetadataItemByNamePlural = (namePlural: string) => + objectMetadataItems.find( + (objectMetadataItem) => objectMetadataItem.namePlural === namePlural, + ); + + return { + activeObjectMetadataItems, + findActiveObjectMetadataItemBySlug, + findObjectMetadataItemById, + findObjectMetadataItemByNamePlural, + inactiveObjectMetadataItems, + objectMetadataItems, + }; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts index b90dd55c3259..203550cb61aa 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useFindManyObjectMetadataItems.ts @@ -28,7 +28,7 @@ export const useFindManyObjectMetadataItems = ({ const { enqueueSnackBar } = useSnackBar(); - const { data, loading, error } = useQuery< + const { data, loading, error, refetch } = useQuery< ObjectMetadataItemsQuery, ObjectMetadataItemsQueryVariables >(FIND_MANY_OBJECT_METADATA_ITEMS, { @@ -59,5 +59,6 @@ export const useFindManyObjectMetadataItems = ({ objectMetadataItems, loading, error, + refetch, }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetObjectOrderByField.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetObjectOrderByField.ts index bd7bd3702058..111058a5efec 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useGetObjectOrderByField.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetObjectOrderByField.ts @@ -1,14 +1,20 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { OrderBy } from '@/object-metadata/types/OrderBy'; import { OrderByField } from '@/object-metadata/types/OrderByField'; -import { getObjectOrderByField } from '@/object-metadata/utils/getObjectOrderByField'; +import { getOrderByFieldForObjectMetadataItem } from '@/object-metadata/utils/getObjectOrderByField'; export const useGetObjectOrderByField = ({ - objectMetadataItem, + objectNameSingular, }: { - objectMetadataItem: ObjectMetadataItem; + objectNameSingular: string; }) => { - return (orderBy: OrderBy): OrderByField => { - return getObjectOrderByField(objectMetadataItem, orderBy); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const getObjectOrderByField = (orderBy: OrderBy): OrderByField => { + return getOrderByFieldForObjectMetadataItem(objectMetadataItem, orderBy); }; + + return { getObjectOrderByField }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts index 8b8765d4fe4f..052caea6c53e 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useGetRelationMetadata.ts @@ -46,7 +46,7 @@ export const useGetRelationMetadata = () => objectNameType: 'singular', }), ) - .valueOrThrow(); + .getValue(); if (!relationObjectMetadataItem) return null; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useLabelIdentifierFieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useLabelIdentifierFieldMetadataItem.ts new file mode 100644 index 000000000000..9970267052f8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useLabelIdentifierFieldMetadataItem.ts @@ -0,0 +1,17 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; + +export const useLabelIdentifierFieldMetadataItem = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + return { labelIdentifierFieldMetadataItem }; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts deleted file mode 100644 index 7c775d4adef8..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { FieldType } from '@/object-record/record-field/types/FieldType'; - -import { FieldMetadataItem } from '../types/FieldMetadataItem'; - -export const useMapFieldMetadataToGraphQLQuery = () => { - const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - - const mapFieldMetadataToGraphQLQuery = ({ - field, - maxDepthForRelations = 2, - onlyTypenameAndIdOnDeepestRelationFields = false, - }: { - field: FieldMetadataItem; - maxDepthForRelations?: number; - onlyTypenameAndIdOnDeepestRelationFields?: boolean; - }): any => { - if (maxDepthForRelations <= 0) { - return ''; - } - - // TODO: parse - const fieldType = field.type as FieldType; - - const fieldIsSimpleValue = ( - [ - 'UUID', - 'TEXT', - 'PHONE', - 'DATE_TIME', - 'EMAIL', - 'NUMBER', - 'BOOLEAN', - 'RATING', - 'SELECT', - ] as FieldType[] - ).includes(fieldType); - - if (fieldIsSimpleValue) { - return field.name; - } else if ( - fieldType === 'RELATION' && - field.toRelationMetadata?.relationType === 'ONE_TO_MANY' - ) { - const relationMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === - (field.toRelationMetadata as any)?.fromObjectMetadata?.id, - ); - - let subfieldQuery = ''; - - if (maxDepthForRelations > 0) { - subfieldQuery = `${(relationMetadataItem?.fields ?? []) - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: maxDepthForRelations - 1, - onlyTypenameAndIdOnDeepestRelationFields, - }), - ) - .join('\n')}`; - } - - return `${field.name} - { - __typename - id - ${subfieldQuery} - }`; - } else if ( - fieldType === 'RELATION' && - field.toRelationMetadata?.relationType === 'ONE_TO_ONE' - ) { - const relationMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === - (field.toRelationMetadata as any)?.fromObjectMetadata?.id, - ); - - let subfieldQuery = ''; - - if (maxDepthForRelations > 0) { - subfieldQuery = `${(relationMetadataItem?.fields ?? []) - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: maxDepthForRelations - 1, - onlyTypenameAndIdOnDeepestRelationFields, - }), - ) - .join('\n')}`; - } - - return `${field.name} - { - __typename - id - ${subfieldQuery} - }`; - } else if ( - fieldType === 'RELATION' && - field.fromRelationMetadata?.relationType === 'ONE_TO_MANY' - ) { - const relationMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.id === - (field.fromRelationMetadata as any)?.toObjectMetadata?.id, - ); - - let subfieldQuery = ''; - - if (maxDepthForRelations > 0) { - subfieldQuery = `${(relationMetadataItem?.fields ?? []) - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: maxDepthForRelations - 1, - onlyTypenameAndIdOnDeepestRelationFields, - }), - ) - .join('\n')}`; - } - - return `${field.name} - { - edges { - node { - __typename - id - ${subfieldQuery} - } - } - }`; - } else if (fieldType === 'LINK') { - return ` - ${field.name} - { - label - url - } - `; - } else if (fieldType === 'CURRENCY') { - return ` - ${field.name} - { - amountMicros - currencyCode - } - `; - } else if (fieldType === 'FULL_NAME') { - return ` - ${field.name} - { - firstName - lastName - } - `; - } - }; - - return mapFieldMetadataToGraphQLQuery; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts index f25b74293374..4de7ad2c9ea8 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useMapToObjectRecordIdentifier.ts @@ -1,8 +1,19 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -export const useMapToObjectRecordIdentifier = - ({ objectMetadataItem }: { objectMetadataItem: ObjectMetadataItem }) => - (record: ObjectRecord) => - getObjectRecordIdentifier({ objectMetadataItem, record }); +export const useMapToObjectRecordIdentifier = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const mapToObjectRecordIdentifier = (record: ObjectRecord) => { + return getObjectRecordIdentifier({ objectMetadataItem, record }); + }; + + return { mapToObjectRecordIdentifier }; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts index 95b88de5e9f1..5c8472a54e47 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItem.ts @@ -1,46 +1,17 @@ -import { gql } from '@apollo/client'; import { useRecoilValue } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts'; import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError'; -import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOrderByField'; -import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; -import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; -import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; -import { useGenerateCreateManyRecordMutation } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; -import { useGenerateCreateOneRecordMutation } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; -import { useGenerateDeleteManyRecordMutation } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; -import { useGenerateExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation'; -import { useGenerateFindDuplicateRecordsQuery } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery'; -import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery'; -import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery'; -import { useGenerateUpdateOneRecordMutation } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; -import { generateDeleteOneRecordMutation } from '@/object-record/utils/generateDeleteOneRecordMutation'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier'; -export const EMPTY_QUERY = gql` - query EmptyQuery { - empty - } -`; - -export const EMPTY_MUTATION = gql` - mutation EmptyMutation { - empty - } -`; - -export const useObjectMetadataItem = ( - { objectNameSingular }: ObjectMetadataItemIdentifier, - depth?: number, -) => { +export const useObjectMetadataItem = ({ + objectNameSingular, +}: ObjectMetadataItemIdentifier) => { const currentWorkspace = useRecoilValue(currentWorkspaceState); const mockObjectMetadataItems = getObjectMetadataItemsMock(); @@ -63,96 +34,14 @@ export const useObjectMetadataItem = ( objectMetadataItems = mockObjectMetadataItems; } - if (!isNonNullable(objectMetadataItem)) { + if (!isDefined(objectMetadataItem)) { throw new ObjectMetadataItemNotFoundError( objectNameSingular, objectMetadataItems, ); } - const mapToObjectRecordIdentifier = useMapToObjectRecordIdentifier({ - objectMetadataItem, - }); - - const getObjectOrderByField = useGetObjectOrderByField({ - objectMetadataItem, - }); - - const getRecordFromCache = useGetRecordFromCache({ - objectMetadataItem, - }); - - const modifyRecordFromCache = useModifyRecordFromCache({ - objectMetadataItem, - }); - - const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery(); - const findManyRecordsQuery = generateFindManyRecordsQuery({ - objectMetadataItem, - depth, - }); - - const generateFindDuplicateRecordsQuery = - useGenerateFindDuplicateRecordsQuery(); - const findDuplicateRecordsQuery = generateFindDuplicateRecordsQuery({ - objectMetadataItem, - depth, - }); - - const generateFindOneRecordQuery = useGenerateFindOneRecordQuery(); - const findOneRecordQuery = generateFindOneRecordQuery({ - objectMetadataItem, - depth, - }); - - const createOneRecordMutation = useGenerateCreateOneRecordMutation({ - objectMetadataItem, - }); - - const createManyRecordsMutation = useGenerateCreateManyRecordMutation({ - objectMetadataItem, - }); - - const updateOneRecordMutation = useGenerateUpdateOneRecordMutation({ - objectMetadataItem, - }); - - const deleteOneRecordMutation = generateDeleteOneRecordMutation({ - objectMetadataItem, - }); - - const deleteManyRecordsMutation = useGenerateDeleteManyRecordMutation({ - objectMetadataItem, - }); - - const executeQuickActionOnOneRecordMutation = - useGenerateExecuteQuickActionOnOneRecordMutation({ - objectMetadataItem, - }); - - const labelIdentifierFieldMetadata = - getLabelIdentifierFieldMetadataItem(objectMetadataItem); - - const basePathToShowPage = getBasePathToShowPage({ - objectMetadataItem, - }); - return { - labelIdentifierFieldMetadata, - basePathToShowPage, objectMetadataItem, - getRecordFromCache, - modifyRecordFromCache, - findManyRecordsQuery, - findDuplicateRecordsQuery, - findOneRecordQuery, - createOneRecordMutation, - updateOneRecordMutation, - deleteOneRecordMutation, - executeQuickActionOnOneRecordMutation, - createManyRecordsMutation, - deleteManyRecordsMutation, - mapToObjectRecordIdentifier, - getObjectOrderByField, }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemForSettings.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemForSettings.ts deleted file mode 100644 index 773a46713340..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemForSettings.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; - -import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; -import { formatObjectMetadataItemInput } from '../utils/formatObjectMetadataItemInput'; -import { getObjectSlug } from '../utils/getObjectSlug'; - -import { useCreateOneObjectRecordMetadataItem } from './useCreateOneObjectMetadataItem'; -import { useDeleteOneObjectMetadataItem } from './useDeleteOneObjectMetadataItem'; -import { useUpdateOneObjectMetadataItem } from './useUpdateOneObjectMetadataItem'; - -export const useObjectMetadataItemForSettings = () => { - const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - - const activeObjectMetadataItems = objectMetadataItems.filter( - ({ isActive, isSystem }) => isActive && !isSystem, - ); - const disabledObjectMetadataItems = objectMetadataItems.filter( - ({ isActive, isSystem }) => !isActive && !isSystem, - ); - - const findActiveObjectMetadataItemBySlug = (slug: string) => - activeObjectMetadataItems.find( - (activeObjectMetadataItem) => - getObjectSlug(activeObjectMetadataItem) === slug, - ); - - const findObjectMetadataItemById = (id: string) => - objectMetadataItems.find( - (objectMetadataItem) => objectMetadataItem.id === id, - ); - - const findObjectMetadataItemByNamePlural = (namePlural: string) => - objectMetadataItems.find( - (objectMetadataItem) => objectMetadataItem.namePlural === namePlural, - ); - - const { createOneObjectMetadataItem } = - useCreateOneObjectRecordMetadataItem(); - const { updateOneObjectMetadataItem } = useUpdateOneObjectMetadataItem(); - const { deleteOneObjectMetadataItem } = useDeleteOneObjectMetadataItem(); - - const createObjectMetadataItem = ( - input: Pick< - ObjectMetadataItem, - 'labelPlural' | 'labelSingular' | 'icon' | 'description' - >, - ) => createOneObjectMetadataItem(formatObjectMetadataItemInput(input)); - - const editObjectMetadataItem = ( - input: Pick< - ObjectMetadataItem, - | 'description' - | 'icon' - | 'id' - | 'labelIdentifierFieldMetadataId' - | 'labelPlural' - | 'labelSingular' - >, - ) => - updateOneObjectMetadataItem({ - idToUpdate: input.id, - updatePayload: formatObjectMetadataItemInput(input), - }); - - const activateObjectMetadataItem = ( - objectMetadataItem: Pick, - ) => - updateOneObjectMetadataItem({ - idToUpdate: objectMetadataItem.id, - updatePayload: { isActive: true }, - }); - - const disableObjectMetadataItem = ( - objectMetadataItem: Pick, - ) => - updateOneObjectMetadataItem({ - idToUpdate: objectMetadataItem.id, - updatePayload: { isActive: false }, - }); - - const eraseObjectMetadataItem = ( - objectMetadataItem: Pick, - ) => deleteOneObjectMetadataItem(objectMetadataItem.id); - - return { - activateObjectMetadataItem, - activeObjectMetadataItems, - createObjectMetadataItem, - disabledObjectMetadataItems, - disableObjectMetadataItem, - editObjectMetadataItem, - eraseObjectMetadataItem, - findActiveObjectMetadataItemBySlug, - findObjectMetadataItemById, - findObjectMetadataItemByNamePlural, - objectMetadataItems, - }; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemOnly.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemOnly.ts deleted file mode 100644 index 12dc6f28e1e8..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectMetadataItemOnly.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts'; -import { ObjectMetadataItemNotFoundError } from '@/object-metadata/errors/ObjectMetadataNotFoundError'; -import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { isNonNullable } from '~/utils/isNonNullable'; - -import { ObjectMetadataItemIdentifier } from '../types/ObjectMetadataItemIdentifier'; - -export const useObjectMetadataItemOnly = ({ - objectNameSingular, -}: ObjectMetadataItemIdentifier) => { - const currentWorkspace = useRecoilValue(currentWorkspaceState); - - const mockObjectMetadataItems = getObjectMetadataItemsMock(); - - let objectMetadataItem = useRecoilValue( - objectMetadataItemFamilySelector({ - objectName: objectNameSingular, - objectNameType: 'singular', - }), - ); - - let objectMetadataItems = useRecoilValue(objectMetadataItemsState); - - if (currentWorkspace?.activationStatus !== 'active') { - objectMetadataItem = - mockObjectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.nameSingular === objectNameSingular, - ) ?? null; - objectMetadataItems = mockObjectMetadataItems; - } - - if (!isNonNullable(objectMetadataItem)) { - throw new ObjectMetadataItemNotFoundError( - objectNameSingular, - objectMetadataItems, - ); - } - - return { - objectMetadataItem, - }; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNamePluralFromSingular.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNamePluralFromSingular.ts index 41200610824e..3cf1c60bbb7c 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNamePluralFromSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNamePluralFromSingular.ts @@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useObjectNamePluralFromSingular = ({ objectNameSingular, @@ -28,7 +28,7 @@ export const useObjectNamePluralFromSingular = ({ ) ?? null; } - if (!isNonNullable(objectMetadataItem)) { + if (!isDefined(objectMetadataItem)) { throw new Error( `Object metadata item not found for ${objectNameSingular} object`, ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNameSingularFromPlural.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNameSingularFromPlural.ts index 6a00794f8a5f..432a3b718700 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNameSingularFromPlural.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useObjectNameSingularFromPlural.ts @@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useObjectNameSingularFromPlural = ({ objectNamePlural, @@ -29,7 +29,7 @@ export const useObjectNameSingularFromPlural = ({ ) ?? null; } - if (!isNonNullable(objectMetadataItem)) { + if (!isDefined(objectMetadataItem)) { throw new Error( `Object metadata item not found for ${objectNamePlural} object`, ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts index fe0ef2854ccb..aaa7b0ae7754 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useUpdateOneObjectMetadataItem.ts @@ -2,6 +2,7 @@ import { useMutation } from '@apollo/client'; import { getOperationName } from '@apollo/client/utilities'; import { + UpdateObjectInput, UpdateOneObjectMetadataItemMutation, UpdateOneObjectMetadataItemMutationVariables, } from '~/generated-metadata/graphql'; @@ -27,16 +28,7 @@ export const useUpdateOneObjectMetadataItem = () => { updatePayload, }: { idToUpdate: UpdateOneObjectMetadataItemMutationVariables['idToUpdate']; - updatePayload: Pick< - UpdateOneObjectMetadataItemMutationVariables['updatePayload'], - | 'description' - | 'icon' - | 'isActive' - | 'labelPlural' - | 'labelSingular' - | 'namePlural' - | 'nameSingular' - >; + updatePayload: UpdateObjectInput; }) => { return await mutate({ variables: { diff --git a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsState.ts b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsState.ts index 516a64403718..5c247fe0e1e5 100644 --- a/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsState.ts +++ b/packages/twenty-front/src/modules/object-metadata/states/objectMetadataItemsState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -export const objectMetadataItemsState = atom({ +export const objectMetadataItemsState = createState({ key: 'objectMetadataItemsState', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts index e148338d90e4..18405ff2232c 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/CoreObjectNameSingular.ts @@ -4,9 +4,12 @@ export enum CoreObjectNameSingular { ApiKey = 'apiKey', Attachment = 'attachment', Blocklist = 'blocklist', + CalendarChannel = 'calendarChannel', + CalendarEvent = 'calendarEvent', Comment = 'comment', Company = 'company', ConnectedAccount = 'connectedAccount', + Event = 'event', Favorite = 'favorite', Message = 'message', MessageChannel = 'messageChannel', @@ -14,7 +17,6 @@ export enum CoreObjectNameSingular { MessageThread = 'messageThread', Opportunity = 'opportunity', Person = 'person', - PipelineStep = 'pipelineStep', View = 'view', ViewField = 'viewField', ViewFilter = 'viewFilter', diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts index 9cf5f15d3480..396fdf4af8e2 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts @@ -1,5 +1,18 @@ import { ThemeColor } from '@/ui/theme/constants/MainColorNames'; -import { Field, Relation } from '~/generated-metadata/graphql'; +import { + Field, + Object as MetadataObject, + Relation, + RelationDefinitionType, +} from '~/generated-metadata/graphql'; + +export type FieldMetadataItemOption = { + color: ThemeColor; + id: string; + label: string; + position: number; + value: string; +}; export type FieldMetadataItem = Omit< Field, @@ -8,6 +21,7 @@ export type FieldMetadataItem = Omit< | 'toRelationMetadata' | 'defaultValue' | 'options' + | 'relationDefinition' > & { __typename?: string; fromRelationMetadata?: @@ -27,11 +41,18 @@ export type FieldMetadataItem = Omit< }) | null; defaultValue?: any; - options?: { - color: ThemeColor; - id: string; - label: string; - position: number; - value: string; - }[]; + options?: FieldMetadataItemOption[]; + relationDefinition?: { + direction: RelationDefinitionType; + sourceFieldMetadata: Pick; + sourceObjectMetadata: Pick< + MetadataObject, + 'id' | 'nameSingular' | 'namePlural' + >; + targetFieldMetadata: Pick; + targetObjectMetadata: Pick< + MetadataObject, + 'id' | 'nameSingular' | 'namePlural' + >; + } | null; }; diff --git a/packages/twenty-front/src/modules/object-metadata/types/Position.ts b/packages/twenty-front/src/modules/object-metadata/types/Position.ts new file mode 100644 index 000000000000..d7316c41d055 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/types/Position.ts @@ -0,0 +1 @@ +export type Position = number | 'first' | 'last'; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts index 12e724bbfade..a85741c63476 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/formatFieldMetadataItemInput.test.ts @@ -1,3 +1,5 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { formatFieldMetadataItemInput, getOptionValueFromLabel, @@ -46,6 +48,7 @@ describe('formatFieldMetadataItemInput', () => { const input = { label: 'Example Label', icon: 'example-icon', + type: FieldMetadataType.Select, description: 'Example description', options: [ { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, @@ -74,7 +77,7 @@ describe('formatFieldMetadataItemInput', () => { value: 'OPTION_2', }, ], - defaultValue: 'OPTION_1', + defaultValue: "'OPTION_1'", }; const result = formatFieldMetadataItemInput(input); @@ -86,6 +89,70 @@ describe('formatFieldMetadataItemInput', () => { const input = { label: 'Example Label', icon: 'example-icon', + type: FieldMetadataType.Select, + description: 'Example description', + }; + + const expected = { + description: 'Example description', + icon: 'example-icon', + label: 'Example Label', + name: 'exampleLabel', + options: undefined, + defaultValue: undefined, + }; + + const result = formatFieldMetadataItemInput(input); + + expect(result).toEqual(expected); + }); + + it('should format the field metadata item multi select input correctly', () => { + const input = { + label: 'Example Label', + icon: 'example-icon', + type: FieldMetadataType.MultiSelect, + description: 'Example description', + options: [ + { id: '1', label: 'Option 1', color: 'red' as const, isDefault: true }, + { id: '2', label: 'Option 2', color: 'blue' as const, isDefault: true }, + ], + }; + + const expected = { + description: 'Example description', + icon: 'example-icon', + label: 'Example Label', + name: 'exampleLabel', + options: [ + { + id: '1', + label: 'Option 1', + color: 'red', + position: 0, + value: 'OPTION_1', + }, + { + id: '2', + label: 'Option 2', + color: 'blue', + position: 1, + value: 'OPTION_2', + }, + ], + defaultValue: ["'OPTION_1'", "'OPTION_2'"], + }; + + const result = formatFieldMetadataItemInput(input); + + expect(result).toEqual(expected); + }); + + it('should handle multi select input without options', () => { + const input = { + label: 'Example Label', + icon: 'example-icon', + type: FieldMetadataType.MultiSelect, description: 'Example description', }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectOrderByField.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectOrderByField.test.ts index 591711e36683..7cecf5668b25 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectOrderByField.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/getObjectOrderByField.test.ts @@ -1,5 +1,5 @@ import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { getObjectOrderByField } from '@/object-metadata/utils/getObjectOrderByField'; +import { getOrderByFieldForObjectMetadataItem } from '@/object-metadata/utils/getObjectOrderByField'; const mockObjectMetadataItems = getObjectMetadataItemsMock(); @@ -8,7 +8,7 @@ describe('getObjectOrderByField', () => { const objectMetadataItem = mockObjectMetadataItems.find( (item) => item.nameSingular === 'person', )!; - const res = getObjectOrderByField(objectMetadataItem); + const res = getOrderByFieldForObjectMetadataItem(objectMetadataItem); expect(res).toEqual({ name: { firstName: 'AscNullsLast', lastName: 'AscNullsLast' }, }); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx new file mode 100644 index 000000000000..c13ec68d54a6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapFieldMetadataToGraphQLQuery.test.tsx @@ -0,0 +1,343 @@ +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { mapFieldMetadataToGraphQLQuery } from '@/object-metadata/utils/mapFieldMetadataToGraphQLQuery'; + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +const formatGQLString = (inputString: string) => + inputString.replace(/^\s*[\r\n]/gm, ''); + +const personObjectMetadataItem = mockObjectMetadataItems.find( + (item) => item.nameSingular === 'person', +); + +if (!personObjectMetadataItem) { + throw new Error('ObjectMetadataItem not found'); +} + +describe('mapFieldMetadataToGraphQLQuery', () => { + it('should return fieldName if simpleValue', async () => { + const res = mapFieldMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + field: personObjectMetadataItem.fields.find( + (field) => field.name === 'id', + )!, + }); + expect(formatGQLString(res)).toEqual('id'); + }); + it('should return fieldName if composite', async () => { + const res = mapFieldMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + field: personObjectMetadataItem.fields.find( + (field) => field.name === 'name', + )!, + }); + expect(formatGQLString(res)).toEqual(`name +{ + firstName + lastName +}`); + }); + it('should not return relation if depth is < 1', async () => { + const res = mapFieldMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + depth: 0, + field: personObjectMetadataItem.fields.find( + (field) => field.name === 'company', + )!, + }); + expect(formatGQLString(res)).toEqual(''); + }); + + it('should return relation if it matches depth', async () => { + const res = mapFieldMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + depth: 1, + field: personObjectMetadataItem.fields.find( + (field) => field.name === 'company', + )!, + }); + expect(formatGQLString(res)).toEqual(`company +{ +__typename +xLink +{ + label + url +} +linkedinLink +{ + label + url +} +domainName +annualRecurringRevenue +{ + amountMicros + currencyCode +} +createdAt +address +updatedAt +name +accountOwnerId +employees +id +idealCustomerProfile +}`); + }); + it('should return relation with all sub relations if it matches depth', async () => { + const res = mapFieldMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + depth: 2, + field: personObjectMetadataItem.fields.find( + (field) => field.name === 'company', + )!, + }); + expect(formatGQLString(res)).toEqual(`company +{ +__typename +xLink +{ + label + url +} +accountOwner +{ +__typename +colorScheme +name +{ + firstName + lastName +} +locale +userId +avatarUrl +createdAt +updatedAt +id +} +linkedinLink +{ + label + url +} +attachments +{ + edges { + node { +__typename +updatedAt +createdAt +name +personId +activityId +companyId +id +authorId +type +fullPath +} + } +} +domainName +opportunities +{ + edges { + node { +__typename +personId +pointOfContactId +updatedAt +companyId +probability +closeDate +amount +{ + amountMicros + currencyCode +} +id +createdAt +} + } +} +annualRecurringRevenue +{ + amountMicros + currencyCode +} +createdAt +address +updatedAt +activityTargets +{ + edges { + node { +__typename +updatedAt +createdAt +personId +activityId +companyId +id +} + } +} +favorites +{ + edges { + node { +__typename +id +companyId +createdAt +personId +position +workspaceMemberId +updatedAt +} + } +} +people +{ + edges { + node { +__typename +xLink +{ + label + url +} +id +createdAt +city +email +jobTitle +name +{ + firstName + lastName +} +phone +linkedinLink +{ + label + url +} +updatedAt +avatarUrl +companyId +} + } +} +name +accountOwnerId +employees +id +idealCustomerProfile +}`); + }); + + it('should return GraphQL fields based on queryFields', async () => { + const res = mapFieldMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + depth: 2, + queryFields: { + accountOwner: true, + people: true, + xLink: true, + linkedinLink: true, + domainName: true, + annualRecurringRevenue: true, + createdAt: true, + address: true, + updatedAt: true, + name: true, + accountOwnerId: true, + employees: true, + id: true, + idealCustomerProfile: true, + }, + field: personObjectMetadataItem.fields.find( + (field) => field.name === 'company', + )!, + }); + expect(formatGQLString(res)).toEqual(`company +{ +__typename +xLink +{ + label + url +} +accountOwner +{ +__typename +colorScheme +name +{ + firstName + lastName +} +locale +userId +avatarUrl +createdAt +updatedAt +id +} +linkedinLink +{ + label + url +} +domainName +annualRecurringRevenue +{ + amountMicros + currencyCode +} +createdAt +address +updatedAt +people +{ + edges { + node { +__typename +xLink +{ + label + url +} +id +createdAt +city +email +jobTitle +name +{ + firstName + lastName +} +phone +linkedinLink +{ + label + url +} +updatedAt +avatarUrl +companyId +} + } +} +name +accountOwnerId +employees +id +idealCustomerProfile +}`); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx new file mode 100644 index 000000000000..f8f32cead243 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/mapObjectMetadataToGraphQLQuery.test.tsx @@ -0,0 +1,339 @@ +import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; + +const mockObjectMetadataItems = getObjectMetadataItemsMock(); + +const formatGQLString = (inputString: string) => + inputString.replace(/^\s*[\r\n]/gm, ''); + +const personObjectMetadataItem = mockObjectMetadataItems.find( + (item) => item.nameSingular === 'person', +); + +if (!personObjectMetadataItem) { + throw new Error('ObjectMetadataItem not found'); +} + +describe('mapObjectMetadataToGraphQLQuery', () => { + it('should return typename if depth < 0', async () => { + const res = mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + objectMetadataItem: personObjectMetadataItem, + depth: -1, + }); + expect(formatGQLString(res)).toEqual(`{ +__typename +}`); + }); + + it('should return depth 0 if depth = 0', async () => { + const res = mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + objectMetadataItem: personObjectMetadataItem, + depth: 0, + }); + expect(formatGQLString(res)).toEqual(`{ +__typename +xLink +{ + label + url +} +id +createdAt +city +email +jobTitle +name +{ + firstName + lastName +} +phone +linkedinLink +{ + label + url +} +updatedAt +avatarUrl +companyId +}`); + }); + + it('should return depth 1 if depth = 1', async () => { + const res = mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + objectMetadataItem: personObjectMetadataItem, + depth: 1, + }); + expect(formatGQLString(res)).toEqual(`{ +__typename +opportunities +{ + edges { + node { +__typename +personId +pointOfContactId +updatedAt +companyId +probability +closeDate +amount +{ + amountMicros + currencyCode +} +id +createdAt +} + } +} +xLink +{ + label + url +} +id +pointOfContactForOpportunities +{ + edges { + node { +__typename +personId +pointOfContactId +updatedAt +companyId +probability +closeDate +amount +{ + amountMicros + currencyCode +} +id +createdAt +} + } +} +createdAt +company +{ +__typename +xLink +{ + label + url +} +linkedinLink +{ + label + url +} +domainName +annualRecurringRevenue +{ + amountMicros + currencyCode +} +createdAt +address +updatedAt +name +accountOwnerId +employees +id +idealCustomerProfile +} +city +email +activityTargets +{ + edges { + node { +__typename +updatedAt +createdAt +personId +activityId +companyId +id +} + } +} +jobTitle +favorites +{ + edges { + node { +__typename +id +companyId +createdAt +personId +position +workspaceMemberId +updatedAt +} + } +} +attachments +{ + edges { + node { +__typename +updatedAt +createdAt +name +personId +activityId +companyId +id +authorId +type +fullPath +} + } +} +name +{ + firstName + lastName +} +phone +linkedinLink +{ + label + url +} +updatedAt +avatarUrl +companyId +}`); + }); + + it('should query only specified queryFields', async () => { + const res = mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + objectMetadataItem: personObjectMetadataItem, + queryFields: { + company: true, + xLink: true, + id: true, + createdAt: true, + city: true, + email: true, + jobTitle: true, + name: true, + phone: true, + linkedinLink: true, + updatedAt: true, + avatarUrl: true, + companyId: true, + }, + depth: 1, + }); + expect(formatGQLString(res)).toEqual(`{ +__typename +xLink +{ + label + url +} +id +createdAt +company +{ +__typename +xLink +{ + label + url +} +linkedinLink +{ + label + url +} +domainName +annualRecurringRevenue +{ + amountMicros + currencyCode +} +createdAt +address +updatedAt +name +accountOwnerId +employees +id +idealCustomerProfile +} +city +email +jobTitle +name +{ + firstName + lastName +} +phone +linkedinLink +{ + label + url +} +updatedAt +avatarUrl +companyId +}`); + }); + + it('should load only specified query fields', async () => { + const res = mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: mockObjectMetadataItems, + objectMetadataItem: personObjectMetadataItem, + queryFields: { company: true, id: true, name: true }, + depth: 1, + }); + expect(formatGQLString(res)).toEqual(`{ +__typename +id +company +{ +__typename +xLink +{ + label + url +} +linkedinLink +{ + label + url +} +domainName +annualRecurringRevenue +{ + amountMicros + currencyCode +} +createdAt +address +updatedAt +name +accountOwnerId +employees +id +idealCustomerProfile +} +name +{ + firstName + lastName +} +}`); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/__tests__/shouldFieldBeQueried.test.ts b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/shouldFieldBeQueried.test.ts new file mode 100644 index 000000000000..32992648d24d --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/__tests__/shouldFieldBeQueried.test.ts @@ -0,0 +1,117 @@ +import { shouldFieldBeQueried } from '@/object-metadata/utils/shouldFieldBeQueried'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +describe('shouldFieldBeQueried', () => { + describe('if field is not relation', () => { + it('should be queried if depth is undefined', () => { + const res = shouldFieldBeQueried({ + field: { name: 'fieldName', type: FieldMetadataType.Boolean }, + }); + expect(res).toBe(true); + }); + + it('should be queried depth = 0', () => { + const res = shouldFieldBeQueried({ + depth: 0, + field: { name: 'fieldName', type: FieldMetadataType.Boolean }, + }); + expect(res).toBe(true); + }); + + it('should be queried depth > 0', () => { + const res = shouldFieldBeQueried({ + depth: 1, + field: { name: 'fieldName', type: FieldMetadataType.Boolean }, + }); + expect(res).toBe(true); + }); + + it('should NOT be queried depth < 0', () => { + const res = shouldFieldBeQueried({ + depth: -1, + field: { name: 'fieldName', type: FieldMetadataType.Boolean }, + }); + expect(res).toBe(false); + }); + + it('should not depends on queryFields', () => { + const res = shouldFieldBeQueried({ + depth: 0, + queryFields: { + fieldName: true, + }, + field: { name: 'fieldName', type: FieldMetadataType.Boolean }, + }); + expect(res).toBe(true); + }); + }); + + describe('if field is relation', () => { + it('should be queried if queryFields and depth are undefined', () => { + const res = shouldFieldBeQueried({ + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(true); + }); + + it('should be queried if queryFields is undefined and depth = 1', () => { + const res = shouldFieldBeQueried({ + depth: 1, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(true); + }); + + it('should be queried if queryFields is undefined and depth > 1', () => { + const res = shouldFieldBeQueried({ + depth: 2, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(true); + }); + + it('should NOT be queried if queryFields is undefined and depth < 1', () => { + const res = shouldFieldBeQueried({ + depth: 0, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(false); + }); + + it('should be queried if queryFields is matching and depth > 1', () => { + const res = shouldFieldBeQueried({ + depth: 1, + queryFields: { fieldName: true }, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(true); + }); + + it('should NOT be queried if queryFields is matching and depth < 1', () => { + const res = shouldFieldBeQueried({ + depth: 0, + queryFields: { fieldName: true }, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(false); + }); + + it('should NOT be queried if queryFields is not matching (falsy) and depth < 1', () => { + const res = shouldFieldBeQueried({ + depth: 1, + queryFields: { fieldName: false }, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(false); + }); + + it('should NOT be queried if queryFields is not matching and depth < 1', () => { + const res = shouldFieldBeQueried({ + depth: 0, + queryFields: { anotherFieldName: true }, + field: { name: 'fieldName', type: FieldMetadataType.Relation }, + }); + expect(res).toBe(false); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts index 3679055c4c11..0b8ec528ba07 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition.ts @@ -3,8 +3,6 @@ import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelati import { FieldMetadataItem } from '../types/FieldMetadataItem'; -import { parseFieldType } from './parseFieldType'; - export type FieldMetadataItemAsFieldDefinitionProps = { field: FieldMetadataItem; objectMetadataItem: ObjectMetadataItem; @@ -31,7 +29,7 @@ export const formatFieldMetadataItemAsFieldDefinition = ({ label: field.label, showLabel, labelWidth, - type: parseFieldType(field.type), + type: field.type, metadata: { fieldName: field.name, placeHolder: field.label, @@ -45,5 +43,6 @@ export const formatFieldMetadataItemAsFieldDefinition = ({ options: field.options, }, iconName: field.icon ?? 'Icon123', + defaultValue: field.defaultValue, }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts index 805726352260..26f2def0b958 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemInput.ts @@ -1,7 +1,8 @@ import toCamelCase from 'lodash.camelcase'; import toSnakeCase from 'lodash.snakecase'; -import { Field } from '~/generated-metadata/graphql'; +import { Field, FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined.ts'; import { FieldMetadataOption } from '../types/FieldMetadataOption'; @@ -20,21 +21,51 @@ export const getOptionValueFromLabel = (label: string) => { }; export const formatFieldMetadataItemInput = ( - input: Pick & { - options?: FieldMetadataOption[]; - }, + input: Pick< + Field, + 'label' | 'icon' | 'description' | 'defaultValue' | 'type' | 'options' + >, ) => { - const defaultOption = input.options?.find((option) => option.isDefault); + const options = input.options as FieldMetadataOption[]; + let defaultValue = input.defaultValue; + if (input.type === FieldMetadataType.MultiSelect) { + const defaultOptions = options?.filter((option) => option.isDefault); + if (isDefined(defaultOptions)) { + defaultValue = defaultOptions.map( + (defaultOption) => `'${getOptionValueFromLabel(defaultOption.label)}'`, + ); + } + } + if (input.type === FieldMetadataType.Select) { + const defaultOption = options?.find((option) => option.isDefault); + defaultValue = isDefined(defaultOption) + ? `'${getOptionValueFromLabel(defaultOption.label)}'` + : undefined; + } + + // Check if options has unique values + if (options !== undefined) { + // Compute the values based on the label + const values = options.map((option) => + getOptionValueFromLabel(option.label), + ); + + if (new Set(values).size !== options.length) { + throw new Error( + `Options must have unique values, but contains the following duplicates ${values.join( + ', ', + )}`, + ); + } + } return { - defaultValue: defaultOption - ? getOptionValueFromLabel(defaultOption.label) - : undefined, + defaultValue, description: input.description?.trim() ?? null, icon: input.icon, label: input.label.trim(), name: toCamelCase(input.label.trim()), - options: input.options?.map((option, index) => ({ + options: options?.map((option, index) => ({ color: option.color, id: option.id, label: option.label.trim(), diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index 59e622f86c4b..3915413cbf37 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -1,5 +1,6 @@ import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; @@ -17,7 +18,10 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ FieldMetadataType.Number, FieldMetadataType.Link, FieldMetadataType.FullName, + FieldMetadataType.Address, FieldMetadataType.Relation, + FieldMetadataType.Select, + FieldMetadataType.MultiSelect, FieldMetadataType.Currency, ].includes(field.type) ) { @@ -30,7 +34,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ } if (field.type === FieldMetadataType.Relation) { - if (field.fromRelationMetadata) { + if (isDefined(field.fromRelationMetadata)) { return acc; } } @@ -50,22 +54,36 @@ export const formatFieldMetadataItemAsFilterDefinition = ({ field.toRelationMetadata?.fromObjectMetadata.namePlural, relationObjectMetadataNameSingular: field.toRelationMetadata?.fromObjectMetadata.nameSingular, - type: - field.type === FieldMetadataType.DateTime - ? 'DATE_TIME' - : field.type === FieldMetadataType.Link - ? 'LINK' - : field.type === FieldMetadataType.FullName - ? 'FULL_NAME' - : field.type === FieldMetadataType.Number - ? 'NUMBER' - : field.type === FieldMetadataType.Currency - ? 'CURRENCY' - : field.type === FieldMetadataType.Email - ? 'TEXT' - : field.type === FieldMetadataType.Phone - ? 'TEXT' - : field.type === FieldMetadataType.Relation - ? 'RELATION' - : 'TEXT', + type: getFilterTypeFromFieldType(field.type), }); + +export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => { + switch (fieldType) { + case FieldMetadataType.DateTime: + return 'DATE_TIME'; + case FieldMetadataType.Date: + return 'DATE'; + case FieldMetadataType.Link: + return 'LINK'; + case FieldMetadataType.FullName: + return 'FULL_NAME'; + case FieldMetadataType.Number: + return 'NUMBER'; + case FieldMetadataType.Currency: + return 'CURRENCY'; + case FieldMetadataType.Email: + return 'EMAIL'; + case FieldMetadataType.Phone: + return 'PHONE'; + case FieldMetadataType.Relation: + return 'RELATION'; + case FieldMetadataType.Select: + return 'SELECT'; + case FieldMetadataType.MultiSelect: + return 'MULTI_SELECT'; + case FieldMetadataType.Address: + return 'ADDRESS'; + default: + return 'TEXT'; + } +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions.ts index 736b8b51273f..5138491cd0e4 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions.ts @@ -12,9 +12,11 @@ export const formatFieldMetadataItemsAsSortDefinitions = ({ if ( ![ FieldMetadataType.DateTime, + FieldMetadataType.Date, FieldMetadataType.Number, FieldMetadataType.Text, FieldMetadataType.Boolean, + FieldMetadataType.Select, ].includes(field.type) ) { return acc; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatObjectMetadataItemInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatObjectMetadataItemInput.ts deleted file mode 100644 index 0afceb29498f..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatObjectMetadataItemInput.ts +++ /dev/null @@ -1,23 +0,0 @@ -import toCamelCase from 'lodash.camelcase'; - -import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; - -export const formatObjectMetadataItemInput = ( - input: Pick< - ObjectMetadataItem, - | 'description' - | 'icon' - | 'labelIdentifierFieldMetadataId' - | 'labelPlural' - | 'labelSingular' - >, -) => ({ - description: input.description?.trim() ?? null, - icon: input.icon, - labelIdentifierFieldMetadataId: - input.labelIdentifierFieldMetadataId?.trim() ?? null, - labelPlural: input.labelPlural.trim(), - labelSingular: input.labelSingular.trim(), - namePlural: toCamelCase(input.labelPlural.trim()), - nameSingular: toCamelCase(input.labelSingular.trim()), -}); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts index 94de1d58a116..12db079c1f8b 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatRelationMetadataInput.ts @@ -9,10 +9,10 @@ import { formatFieldMetadataItemInput } from './formatFieldMetadataItemInput'; export type FormatRelationMetadataInputParams = { relationType: RelationType; - field: Pick; + field: Pick; objectMetadataId: string; connect: { - field: Pick; + field: Pick; objectMetadataId: string; }; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getActiveFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/getActiveFieldMetadataItems.ts new file mode 100644 index 000000000000..e174c2b00489 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getActiveFieldMetadataItems.ts @@ -0,0 +1,9 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const getActiveFieldMetadataItems = ( + objectMetadataItem: Pick, +) => + objectMetadataItem.fields.filter( + (fieldMetadataItem) => + fieldMetadataItem.isActive && !fieldMetadataItem.isSystem, + ); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts b/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts index 7a42595acb0c..1d5999c97ad1 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getBasePathToShowPage.ts @@ -1,11 +1,9 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; - export const getBasePathToShowPage = ({ - objectMetadataItem, + objectNameSingular, }: { - objectMetadataItem: ObjectMetadataItem; + objectNameSingular: string; }) => { - const basePathToShowPage = `/object/${objectMetadataItem.nameSingular}/`; + const basePathToShowPage = `/object/${objectNameSingular}/`; return basePathToShowPage; }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts b/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts new file mode 100644 index 000000000000..ccd122331dd7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getDefaultValueForBackend.ts @@ -0,0 +1,19 @@ +import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const getDefaultValueForBackend = ( + defaultValue: any, + fieldMetadataType: FieldMetadataType, +) => { + if (fieldMetadataType === FieldMetadataType.Currency) { + const currencyDefaultValue = defaultValue as FieldCurrencyValue; + return { + amountMicros: currencyDefaultValue.amountMicros, + currencyCode: `'${currencyDefaultValue.currencyCode}'` as any, + } satisfies FieldCurrencyValue; + } else if (typeof defaultValue === 'string') { + return `'${defaultValue}'`; + } + + return defaultValue; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getDisabledFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/getDisabledFieldMetadataItems.ts new file mode 100644 index 000000000000..52f781ccc203 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getDisabledFieldMetadataItems.ts @@ -0,0 +1,9 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; + +export const getDisabledFieldMetadataItems = ( + objectMetadataItem: Pick, +) => + objectMetadataItem.fields.filter( + (fieldMetadataItem) => + !fieldMetadataItem.isActive && !fieldMetadataItem.isSystem, + ); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getFieldRelationDirections.ts b/packages/twenty-front/src/modules/object-metadata/utils/getFieldRelationDirections.ts new file mode 100644 index 000000000000..6b8929c32457 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getFieldRelationDirections.ts @@ -0,0 +1,38 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { RelationDirections } from '@/object-record/record-field/types/FieldDefinition'; +import { + FieldMetadataType, + RelationDefinitionType, +} from '~/generated-metadata/graphql'; + +export const getFieldRelationDirections = ( + field: Pick | undefined, +): RelationDirections => { + if (!field || field.type !== FieldMetadataType.Relation) { + throw new Error(`Field is not a relation field.`); + } + + switch (field.relationDefinition?.direction) { + case RelationDefinitionType.ManyToMany: + throw new Error(`Many to many relations are not supported.`); + case RelationDefinitionType.OneToMany: + return { + from: 'FROM_ONE_OBJECT', + to: 'TO_MANY_OBJECTS', + }; + case RelationDefinitionType.ManyToOne: + return { + from: 'FROM_MANY_OBJECTS', + to: 'TO_ONE_OBJECT', + }; + case RelationDefinitionType.OneToOne: + return { + from: 'FROM_ONE_OBJECT', + to: 'TO_ONE_OBJECT', + }; + default: + throw new Error( + `Invalid relation definition type direction : ${field.relationDefinition?.direction}`, + ); + } +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataIdentifierFields.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataIdentifierFields.ts new file mode 100644 index 000000000000..65d4e41ee6bb --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataIdentifierFields.ts @@ -0,0 +1,20 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; + +export const getObjectMetadataIdentifierFields = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const labelIdentifierFieldMetadataItem = + getLabelIdentifierFieldMetadataItem(objectMetadataItem); + + const imageIdentifierFieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === objectMetadataItem.imageIdentifierFieldMetadataId, + ); + + return { + labelIdentifierFieldMetadataItem, + imageIdentifierFieldMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts index d6b0c4350942..3edc7af06ed1 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectMetadataItemsMock.ts @@ -13,6 +13,7 @@ export const getObjectMetadataItemsMock = () => { description: 'A webhook', icon: 'IconRobot', isCustom: false, + isRemote: false, isActive: true, isSystem: true, createdAt: '2023-11-30T11:13:15.206Z', @@ -30,6 +31,7 @@ export const getObjectMetadataItemsMock = () => { description: 'An api key', icon: 'IconRobot', isCustom: false, + isRemote: false, isActive: true, isSystem: true, createdAt: '2023-11-30T11:13:15.206Z', @@ -150,6 +152,7 @@ export const getObjectMetadataItemsMock = () => { description: '(System) View Sorts', icon: 'IconArrowsSort', isCustom: false, + isRemote: false, isActive: true, isSystem: true, createdAt: '2023-11-30T11:13:15.206Z', @@ -271,6 +274,24 @@ export const getObjectMetadataItemsMock = () => { }, ], }, + { + __typename: 'object', + id: '20202020-ddee-40de-9c9b-5f82a3503361', + dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', + nameSingular: 'calendarEvent', + namePlural: 'calendarEvents', + labelSingular: 'Calendar Event', + labelPlural: 'Calendar Events', + description: 'A calendar event', + icon: 'IconCalendarEvent', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: true, + createdAt: '2023-11-30T11:13:15.206Z', + updatedAt: '2023-11-30T11:13:15.206Z', + fields: [], + }, { __typename: 'object', id: '20202020-cae9-4ff4-9579-f7d9fe44c937', @@ -282,6 +303,7 @@ export const getObjectMetadataItemsMock = () => { description: 'An opportunity', icon: 'IconTargetArrow', isCustom: false, + isRemote: false, isActive: true, isSystem: false, createdAt: '2023-11-30T11:13:15.206Z', @@ -384,23 +406,6 @@ export const getObjectMetadataItemsMock = () => { fromRelationMetadata: null, toRelationMetadata: null, }, - { - __typename: 'field', - id: '20202020-0a2e-4676-8011-3fdb2c30d7f8', - type: 'UUID', - name: 'pipelineStepId', - label: 'Pipeline Step ID (foreign key)', - description: 'Foreign key for pipeline step', - icon: null, - isCustom: false, - isActive: true, - isSystem: true, - isNullable: true, - createdAt: '2023-11-30T11:13:15.308Z', - updatedAt: '2023-11-30T11:13:15.308Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, { __typename: 'field', id: '20202020-3b9c-4e58-a3d2-c617d3b596b1', @@ -419,32 +424,63 @@ export const getObjectMetadataItemsMock = () => { toRelationMetadata: null, }, { - __typename: 'field', - id: '20202020-0a2e-4676-8011-3fdb2c30c258', - type: 'RELATION', - name: 'pipelineStep', - label: 'Pipeline Step', - description: 'Opportunity pipeline step', - icon: 'IconKanban', - isCustom: false, - isActive: true, - isSystem: true, - isNullable: true, - createdAt: '2023-11-30T11:13:15.308Z', - updatedAt: '2023-11-30T11:13:15.308Z', - fromRelationMetadata: null, - toRelationMetadata: { - __typename: 'relation', - id: 'dfb44970-3e09-49f2-9f1d-51c8c451b8f5', - relationType: 'ONE_TO_MANY', - fromObjectMetadata: { - __typename: 'object', - id: '20202020-1029-4661-9e91-83bad932bdcd', - dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', - nameSingular: 'pipelineStep', - namePlural: 'pipelineSteps', + __typename: 'fieldEdge', + node: { + __typename: 'field', + id: '20202020-46cc-42bb-90d5-c724921a012d', + type: 'SELECT', + name: 'stage', + label: 'Stage', + description: 'Opportunity stage', + icon: 'IconProgressCheck', + isCustom: false, + isActive: true, + isSystem: false, + isNullable: false, + createdAt: '2024-03-21T16:48:40.384Z', + updatedAt: '2024-03-21T16:48:40.384Z', + defaultValue: { + value: 'NEW', }, - fromFieldMetadataId: '20202020-22c4-443a-b114-43c97dda5867', + options: [ + { + id: '20202020-aa3b-4c0b-bd90-9d071e3b9bf2', + color: 'red', + label: 'New', + value: 'NEW', + position: 0, + }, + { + id: '20202020-8f9b-4bc3-b0a0-ce6a5085c1cf', + color: 'purple', + label: 'Screening', + value: 'SCREENING', + position: 1, + }, + { + id: '20202020-9797-448d-81e4-49b055a1d19b', + color: 'sky', + label: 'Meeting', + value: 'MEETING', + position: 2, + }, + { + id: '20202020-d542-479c-bc88-3c6d4ee78d09', + color: 'turquoise', + label: 'Proposal', + value: 'PROPOSAL', + position: 3, + }, + { + id: '20202020-b69a-4c9c-ac16-adcba0ec972d', + color: 'yellow', + label: 'Customer', + value: 'CUSTOMER', + position: 4, + }, + ], + fromRelationMetadata: null, + toRelationMetadata: null, }, }, { @@ -586,6 +622,7 @@ export const getObjectMetadataItemsMock = () => { description: 'A person', icon: 'IconUser', isCustom: false, + isRemote: false, isActive: true, isSystem: false, createdAt: '2023-11-30T11:13:15.206Z', @@ -819,7 +856,7 @@ export const getObjectMetadataItemsMock = () => { icon: 'IconHeart', isCustom: false, isActive: true, - isSystem: false, + isSystem: true, isNullable: true, createdAt: '2023-11-30T11:13:15.331Z', updatedAt: '2023-11-30T11:13:15.331Z', @@ -982,6 +1019,7 @@ export const getObjectMetadataItemsMock = () => { description: 'A workspace member', icon: 'IconUserCircle', isCustom: false, + isRemote: false, isActive: true, isSystem: true, createdAt: '2023-11-30T11:13:15.206Z', @@ -1065,7 +1103,7 @@ export const getObjectMetadataItemsMock = () => { icon: 'IconHeart', isCustom: false, isActive: true, - isSystem: false, + isSystem: true, isNullable: true, createdAt: '2023-11-30T11:13:15.392Z', updatedAt: '2023-11-30T11:13:15.392Z', @@ -3011,7 +3049,7 @@ export const getObjectMetadataItemsMock = () => { icon: 'IconHeart', isCustom: false, isActive: true, - isSystem: false, + isSystem: true, isNullable: true, createdAt: '2023-11-30T11:13:15.292Z', updatedAt: '2023-11-30T11:13:15.292Z', @@ -3520,155 +3558,6 @@ export const getObjectMetadataItemsMock = () => { }, ], }, - { - __typename: 'object', - id: '20202020-1029-4661-9e91-83bad932bdcd', - dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', - nameSingular: 'pipelineStep', - namePlural: 'pipelineSteps', - labelSingular: 'Pipeline Step', - labelPlural: 'Pipeline Steps', - description: 'A pipeline step', - icon: 'IconLayoutKanban', - isCustom: false, - isActive: true, - isSystem: true, - createdAt: '2023-11-30T11:13:15.206Z', - updatedAt: '2023-11-30T11:13:15.206Z', - fields: [ - { - __typename: 'field', - id: '20202020-f294-430e-b800-3a411fc05ad3', - type: 'TEXT', - name: 'name', - label: 'Name', - description: 'Pipeline Step name', - icon: 'IconCurrencyDollar', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-039a-4fbd-b4c1-66dfa9e4bd3f', - type: 'UUID', - name: 'id', - label: 'Id', - description: null, - icon: null, - isCustom: false, - isActive: true, - isSystem: true, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-816f-4861-9b36-4a2f8ae2791c', - type: 'DATE_TIME', - name: 'createdAt', - label: 'Creation date', - description: null, - icon: 'IconCalendar', - isCustom: false, - isActive: true, - isSystem: true, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-22c4-443a-b114-43c97dda5867', - type: 'RELATION', - name: 'opportunities', - label: 'Opportunities', - description: 'Opportunities linked to the step.', - icon: 'IconTargetArrow', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: true, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: { - __typename: 'relation', - id: 'dfb44970-3e09-49f2-9f1d-51c8c451b8f5', - relationType: 'ONE_TO_MANY', - toObjectMetadata: { - __typename: 'object', - id: '20202020-cae9-4ff4-9579-f7d9fe44c937', - dataSourceId: '20202020-7f63-47a9-b1b3-6c7290ca9fb1', - nameSingular: 'opportunity', - namePlural: 'opportunities', - }, - toFieldMetadataId: '20202020-0a2e-4676-8011-3fdb2c30c258', - }, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-6296-4cab-aafb-121ef5822b13', - type: 'NUMBER', - name: 'position', - label: 'Position', - description: 'Pipeline Step position', - icon: 'IconHierarchy2', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-5b93-4b28-8c45-7988ea68f91b', - type: 'TEXT', - name: 'color', - label: 'Color', - description: 'Pipeline Step color', - icon: 'IconColorSwatch', - isCustom: false, - isActive: true, - isSystem: false, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - { - __typename: 'field', - id: '20202020-2d73-4829-b774-522c2f5627d7', - type: 'DATE_TIME', - name: 'updatedAt', - label: 'Update date', - description: null, - icon: 'IconCalendar', - isCustom: false, - isActive: true, - isSystem: true, - isNullable: false, - createdAt: '2023-11-30T11:13:15.337Z', - updatedAt: '2023-11-30T11:13:15.337Z', - fromRelationMetadata: null, - toRelationMetadata: null, - }, - ], - }, ]; // Todo fix typing here (the backend is not in sync with the frontend) diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts index 0fc1ae8e8608..2219b1a0246b 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectOrderByField.ts @@ -3,15 +3,16 @@ import { OrderBy } from '@/object-metadata/types/OrderBy'; import { OrderByField } from '@/object-metadata/types/OrderByField'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; -export const getObjectOrderByField = ( +export const getOrderByFieldForObjectMetadataItem = ( objectMetadataItem: ObjectMetadataItem, orderBy?: OrderBy | null, ): OrderByField => { const labelIdentifierFieldMetadata = getLabelIdentifierFieldMetadataItem(objectMetadataItem); - if (labelIdentifierFieldMetadata) { + if (isDefined(labelIdentifierFieldMetadata)) { switch (labelIdentifierFieldMetadata.type) { case FieldMetadataType.FullName: return { diff --git a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts index 41d4cea56a46..6b2fb1dfaee5 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/getObjectRecordIdentifier.ts @@ -68,7 +68,7 @@ export const getObjectRecordIdentifier = ({ : imageIdentifierFieldValue) ?? ''; const basePathToShowPage = getBasePathToShowPage({ - objectMetadataItem, + objectNameSingular: objectMetadataItem.nameSingular, }); const isWorkspaceMemberObjectMetadata = diff --git a/packages/twenty-front/src/modules/object-metadata/utils/isLabelIdentifierField.ts b/packages/twenty-front/src/modules/object-metadata/utils/isLabelIdentifierField.ts index 489ea0e266df..06bc0c57613c 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/isLabelIdentifierField.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/isLabelIdentifierField.ts @@ -1,6 +1,6 @@ import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const DEFAULT_LABEL_IDENTIFIER_FIELD_NAME = 'name'; @@ -14,6 +14,6 @@ export const isLabelIdentifierField = ({ 'labelIdentifierFieldMetadataId' >; }) => - isNonNullable(objectMetadataItem.labelIdentifierFieldMetadataId) + isDefined(objectMetadataItem.labelIdentifierFieldMetadataId) ? fieldMetadataItem.id === objectMetadataItem.labelIdentifierFieldMetadataId : fieldMetadataItem.name === DEFAULT_LABEL_IDENTIFIER_FIELD_NAME; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts new file mode 100644 index 000000000000..172288fc9f31 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapFieldMetadataToGraphQLQuery.ts @@ -0,0 +1,134 @@ +import { isUndefined } from '@sniptt/guards'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldMetadataItem } from '../types/FieldMetadataItem'; + +// TODO: change ObjectMetadataItems mock before refactoring with relationDefinition computed field +export const mapFieldMetadataToGraphQLQuery = ({ + objectMetadataItems, + field, + depth = 0, + queryFields, + computeReferences = false, +}: { + objectMetadataItems: ObjectMetadataItem[]; + field: Pick< + FieldMetadataItem, + 'name' | 'type' | 'toRelationMetadata' | 'fromRelationMetadata' + >; + depth?: number; + queryFields?: Record; + computeReferences?: boolean; +}): any => { + const fieldType = field.type; + + const fieldIsSimpleValue = ( + [ + 'UUID', + 'TEXT', + 'PHONE', + 'DATE_TIME', + 'DATE', + 'EMAIL', + 'NUMBER', + 'BOOLEAN', + 'RATING', + 'SELECT', + 'MULTI_SELECT', + 'POSITION', + 'RAW_JSON', + ] as FieldMetadataType[] + ).includes(fieldType); + + if (fieldIsSimpleValue) { + return field.name; + } else if ( + fieldType === 'RELATION' && + field.toRelationMetadata?.relationType === 'ONE_TO_MANY' && + depth > 0 + ) { + const relationMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + (field.toRelationMetadata as any)?.fromObjectMetadata?.id, + ); + + if (isUndefined(relationMetadataItem)) { + return ''; + } + + return `${field.name} +${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem: relationMetadataItem, + depth: depth - 1, + queryFields, + computeReferences: computeReferences, + isRootLevel: false, +})}`; + } else if ( + fieldType === 'RELATION' && + field.fromRelationMetadata?.relationType === 'ONE_TO_MANY' && + depth > 0 + ) { + const relationMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.id === + (field.fromRelationMetadata as any)?.toObjectMetadata?.id, + ); + + if (isUndefined(relationMetadataItem)) { + return ''; + } + + return `${field.name} +{ + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem: relationMetadataItem, + depth: depth - 1, + queryFields, + computeReferences, + isRootLevel: false, + })} + } +}`; + } else if (fieldType === 'LINK') { + return `${field.name} +{ + label + url +}`; + } else if (fieldType === 'CURRENCY') { + return `${field.name} +{ + amountMicros + currencyCode +} + `; + } else if (fieldType === 'FULL_NAME') { + return `${field.name} +{ + firstName + lastName +}`; + } else if (fieldType === 'ADDRESS') { + return `${field.name} +{ + addressStreet1 + addressStreet2 + addressCity + addressState + addressCountry + addressPostcode + addressLat + addressLng +}`; + } + + return ''; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts new file mode 100644 index 000000000000..ebd05e796528 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/mapObjectMetadataToGraphQLQuery.ts @@ -0,0 +1,54 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapFieldMetadataToGraphQLQuery } from '@/object-metadata/utils/mapFieldMetadataToGraphQLQuery'; +import { shouldFieldBeQueried } from '@/object-metadata/utils/shouldFieldBeQueried'; + +export const mapObjectMetadataToGraphQLQuery = ({ + objectMetadataItems, + objectMetadataItem, + depth = 1, + queryFields, + computeReferences = false, + isRootLevel = true, +}: { + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: Pick; + depth?: number; + queryFields?: Record; + computeReferences?: boolean; + isRootLevel?: boolean; +}): any => { + const fieldsThatShouldBeQueried = + objectMetadataItem?.fields + .filter((field) => field.isActive) + .filter((field) => + shouldFieldBeQueried({ + field, + depth, + queryFields, + }), + ) ?? []; + + if (!isRootLevel && computeReferences) { + return `{ + __ref + }`; + } + + return `{ +__typename +${fieldsThatShouldBeQueried + .map((field) => + mapFieldMetadataToGraphQLQuery({ + objectMetadataItems, + field, + depth, + queryFields: + typeof queryFields?.[field.name] === 'boolean' + ? undefined + : queryFields?.[field.name], + computeReferences, + }), + ) + .join('\n')} +}`; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/parseFieldRelationType.ts b/packages/twenty-front/src/modules/object-metadata/utils/parseFieldRelationType.ts index de9d705e4743..65a90c8fa72d 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/parseFieldRelationType.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/parseFieldRelationType.ts @@ -4,7 +4,7 @@ import { FieldMetadataType, RelationMetadataType, } from '~/generated-metadata/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const parseFieldRelationType = ( field: FieldMetadataItem | undefined, @@ -30,14 +30,14 @@ export const parseFieldRelationType = ( }; if ( - isNonNullable(field.fromRelationMetadata) && + isDefined(field.fromRelationMetadata) && field.fromRelationMetadata.relationType in config ) { return config[field.fromRelationMetadata.relationType].from; } if ( - isNonNullable(field.toRelationMetadata) && + isDefined(field.toRelationMetadata) && field.toRelationMetadata.relationType in config ) { return config[field.toRelationMetadata.relationType].to; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/parseFieldType.ts b/packages/twenty-front/src/modules/object-metadata/utils/parseFieldType.ts deleted file mode 100644 index 84ac0e1a99ad..000000000000 --- a/packages/twenty-front/src/modules/object-metadata/utils/parseFieldType.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { FieldType } from '@/object-record/record-field/types/FieldType'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; - -export const parseFieldType = (fieldType: FieldMetadataType): FieldType => { - if (fieldType === FieldMetadataType.Link) { - return 'LINK'; - } - - if (fieldType === FieldMetadataType.Currency) { - return 'CURRENCY'; - } - - return fieldType as FieldType; -}; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts b/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts new file mode 100644 index 000000000000..f663359ad8a8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/shouldFieldBeQueried.ts @@ -0,0 +1,36 @@ +import { isUndefined } from '@sniptt/guards'; + +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; + +import { FieldMetadataItem } from '../types/FieldMetadataItem'; + +export const shouldFieldBeQueried = ({ + field, + depth, + queryFields, +}: { + field: Pick; + depth?: number; + objectRecord?: ObjectRecord; + queryFields?: Record; +}): any => { + if (!isUndefined(depth) && depth < 0) { + return false; + } + + if ( + !isUndefined(depth) && + depth < 1 && + field.type === FieldMetadataType.Relation + ) { + return false; + } + + if (isDefined(queryFields) && !queryFields[field.name]) { + return false; + } + + return true; +}; diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts new file mode 100644 index 000000000000..48db78980598 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/__tests__/objectMetadataItemSchema.test.ts @@ -0,0 +1,48 @@ +import { SafeParseSuccess } from 'zod'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mockedCompanyObjectMetadataItem } from '@/object-record/record-field/__mocks__/fieldDefinitions'; + +import { objectMetadataItemSchema } from '../objectMetadataItemSchema'; + +describe('objectMetadataItemSchema', () => { + it('validates a valid object metadata item', () => { + // Given + const validObjectMetadataItem = mockedCompanyObjectMetadataItem; + + // When + const result = objectMetadataItemSchema.safeParse(validObjectMetadataItem); + + // Then + expect(result.success).toBe(true); + expect((result as SafeParseSuccess).data).toEqual( + validObjectMetadataItem, + ); + }); + + it('fails for an invalid object metadata item', () => { + // Given + const invalidObjectMetadataItem = { + createdAt: 'invalid date', + dataSourceId: 'invalid uuid', + fields: 'not an array', + icon: 'invalid icon', + isActive: 'not a boolean', + isCustom: 'not a boolean', + isSystem: 'not a boolean', + labelPlural: 123, + labelSingular: 123, + namePlural: 'notCamelCase', + nameSingular: 'notCamelCase', + updatedAt: 'invalid date', + }; + + // When + const result = objectMetadataItemSchema.safeParse( + invalidObjectMetadataItem, + ); + + // Then + expect(result.success).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/fieldMetadataItemSchema.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/fieldMetadataItemSchema.ts new file mode 100644 index 000000000000..0943a2e83240 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/fieldMetadataItemSchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; + +// TODO: implement fieldMetadataItemSchema +export const fieldMetadataItemSchema: z.ZodType = z.any(); diff --git a/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts b/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts new file mode 100644 index 000000000000..bbd31e87cd53 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/validation-schemas/objectMetadataItemSchema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { fieldMetadataItemSchema } from '@/object-metadata/validation-schemas/fieldMetadataItemSchema'; +import { camelCaseStringSchema } from '~/utils/validation-schemas/camelCaseStringSchema'; + +export const objectMetadataItemSchema = z.object({ + __typename: z.literal('object').optional(), + createdAt: z.string().datetime(), + dataSourceId: z.string().uuid(), + description: z.string().trim().nullable().optional(), + fields: z.array(fieldMetadataItemSchema), + icon: z.string().startsWith('Icon').trim(), + id: z.string().uuid(), + imageIdentifierFieldMetadataId: z.string().uuid().nullable(), + isActive: z.boolean(), + isCustom: z.boolean(), + isRemote: z.boolean(), + isSystem: z.boolean(), + labelIdentifierFieldMetadataId: z.string().uuid().nullable(), + labelPlural: z.string().trim().min(1), + labelSingular: z.string().trim().min(1), + namePlural: camelCaseStringSchema, + nameSingular: camelCaseStringSchema, + updatedAt: z.string().datetime(), +}) satisfies z.ZodType; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useAddRecordInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useAddRecordInCache.ts deleted file mode 100644 index afc2af6e5b49..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useAddRecordInCache.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useApolloClient } from '@apollo/client'; -import gql from 'graphql-tag'; -import { useRecoilCallback } from 'recoil'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { MAX_QUERY_DEPTH_FOR_CACHE_INJECTION } from '@/object-record/cache/constants/MaxQueryDepthForCacheInjection'; -import { useInjectIntoFindOneRecordQueryCache } from '@/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useAddRecordInCache = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - const apolloClient = useApolloClient(); - - const { injectIntoFindOneRecordQueryCache } = - useInjectIntoFindOneRecordQueryCache({ - objectMetadataItem, - }); - - return useRecoilCallback( - ({ set }) => - (record: ObjectRecord) => { - const fragment = gql` - fragment Create${capitalize( - objectMetadataItem.nameSingular, - )}InCache on ${capitalize(objectMetadataItem.nameSingular)} { - __typename - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: MAX_QUERY_DEPTH_FOR_CACHE_INJECTION, - }), - ) - .join('\n')} - } - `; - - const cachedObjectRecord = { - __typename: `${capitalize(objectMetadataItem.nameSingular)}`, - ...record, - }; - - apolloClient.writeFragment({ - id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`, - fragment, - data: cachedObjectRecord, - }); - - // TODO: should we keep this here ? Or should the caller of createOneRecordInCache/createManyRecordsInCache be responsible for this ? - injectIntoFindOneRecordQueryCache(cachedObjectRecord); - - // TODO: remove this once we get rid of entityFieldsFamilyState - set(recordStoreFamilyState(record.id), record); - }, - [ - objectMetadataItem, - apolloClient, - mapFieldMetadataToGraphQLQuery, - injectIntoFindOneRecordQueryCache, - ], - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useAppendToFindManyRecordsQueryInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useAppendToFindManyRecordsQueryInCache.ts deleted file mode 100644 index a7fe89f4da37..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useAppendToFindManyRecordsQueryInCache.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useReadFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useReadFindManyRecordsQueryInCache'; -import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; - -export const useAppendToFindManyRecordsQueryInCache = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const { readFindManyRecordsQueryInCache } = - useReadFindManyRecordsQueryInCache({ - objectMetadataItem, - }); - - const { - upsertFindManyRecordsQueryInCache: overwriteFindManyRecordsQueryInCache, - } = useUpsertFindManyRecordsQueryInCache({ - objectMetadataItem, - }); - - const appendToFindManyRecordsQueryInCache = < - T extends ObjectRecord = ObjectRecord, - >({ - queryVariables, - objectRecordsToAppend, - }: { - queryVariables: ObjectRecordQueryVariables; - objectRecordsToAppend: T[]; - }) => { - const existingObjectRecords = readFindManyRecordsQueryInCache({ - queryVariables, - }); - - const newObjectRecordList = [ - ...existingObjectRecords, - ...objectRecordsToAppend, - ]; - - overwriteFindManyRecordsQueryInCache({ - objectRecordsToOverwrite: newObjectRecordList, - queryVariables, - }); - }; - - return { - appendToFindManyRecordsQueryInCache, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateManyRecordsInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateManyRecordsInCache.ts new file mode 100644 index 000000000000..31fb896556d8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateManyRecordsInCache.ts @@ -0,0 +1,42 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { prefillRecord } from '@/object-record/utils/prefillRecord'; +import { isDefined } from '~/utils/isDefined'; + +export const useCreateManyRecordsInCache = ({ + objectNameSingular, +}: ObjectMetadataItemIdentifier) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const createOneRecordInCache = useCreateOneRecordInCache({ + objectMetadataItem, + }); + + const createManyRecordsInCache = (recordsToCreate: Partial[]) => { + const recordsWithId = recordsToCreate + .map((record) => { + return prefillRecord({ + input: record, + objectMetadataItem, + }); + }) + .filter(isDefined); + + const createdRecordsInCache = [] as T[]; + + for (const record of recordsWithId) { + if (isDefined(record)) { + createOneRecordInCache(record); + createdRecordsInCache.push(record); + } + } + + return createdRecordsInCache; + }; + + return { createManyRecordsInCache }; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts new file mode 100644 index 000000000000..4ee8ad510f88 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useCreateOneRecordInCache.ts @@ -0,0 +1,62 @@ +import { useApolloClient } from '@apollo/client'; +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { prefillRecord } from '@/object-record/utils/prefillRecord'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useCreateOneRecordInCache = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const apolloClient = useApolloClient(); + + return (record: ObjectRecord) => { + const fragment = gql` + fragment Create${capitalize( + objectMetadataItem.nameSingular, + )}InCache on ${capitalize( + objectMetadataItem.nameSingular, + )} ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + computeReferences: true, + })} + `; + + const prefilledRecord = prefillRecord({ + objectMetadataItem, + input: record, + depth: 1, + }); + + const recordToCreateWithNestedConnections = getRecordNodeFromRecord({ + record: prefilledRecord, + objectMetadataItem, + objectMetadataItems, + }); + + const cachedObjectRecord = { + __typename: `${capitalize(objectMetadataItem.nameSingular)}`, + ...recordToCreateWithNestedConnections, + }; + + apolloClient.writeFragment({ + id: `${capitalize(objectMetadataItem.nameSingular)}:${record.id}`, + fragment, + data: cachedObjectRecord, + }); + return getRecordFromCache(record.id) as T; + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useDeleteRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useDeleteRecordFromCache.ts new file mode 100644 index 000000000000..693236975e51 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useDeleteRecordFromCache.ts @@ -0,0 +1,29 @@ +import { useApolloClient } from '@apollo/client'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { deleteRecordFromCache } from '@/object-record/cache/utils/deleteRecordFromCache'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export const useDeleteRecordFromCache = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const apolloClient = useApolloClient(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + + return (recordToDelete: ObjectRecord) => { + deleteRecordFromCache({ + objectMetadataItem, + objectMetadataItems, + recordToDelete, + cache: apolloClient.cache, + }); + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts deleted file mode 100644 index 348c0cc54c5c..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { v4 } from 'uuid'; -import { z } from 'zod'; - -import { useGetRelationMetadata } from '@/object-metadata/hooks/useGetRelationMetadata'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useGenerateObjectRecordOptimisticResponse = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const getRelationMetadata = useGetRelationMetadata(); - - const generateObjectRecordOptimisticResponse = < - GeneratedObjectRecord extends ObjectRecord, - >( - input: Record, - ) => { - const recordSchema = z.object( - Object.fromEntries( - objectMetadataItem.fields.map((fieldMetadataItem) => [ - fieldMetadataItem.name, - z.unknown().default(generateEmptyFieldValue(fieldMetadataItem)), - ]), - ), - ); - - const inputWithRelationFields = objectMetadataItem.fields.reduce( - (result, fieldMetadataItem) => { - const relationIdFieldName = `${fieldMetadataItem.name}Id`; - - if (!(relationIdFieldName in input)) return result; - - const relationMetadata = getRelationMetadata({ fieldMetadataItem }); - - if (!relationMetadata) return result; - - const relationRecordTypeName = capitalize( - relationMetadata.relationObjectMetadataItem.nameSingular, - ); - const relationRecordId = result[relationIdFieldName] as string | null; - - const relationRecord = input[fieldMetadataItem.name] as - | ObjectRecord - | undefined; - - return { - ...result, - [fieldMetadataItem.name]: relationRecordId - ? { - __typename: relationRecordTypeName, - id: relationRecordId, - // TODO: there are too many bugs if we don't include the entire relation record - // See if we can find a way to work only with the id and typename - ...relationRecord, - } - : null, - }; - }, - input, - ); - - return { - __typename: capitalize(objectMetadataItem.nameSingular), - ...recordSchema.parse({ - id: v4(), - createdAt: new Date().toISOString(), - ...inputWithRelationFields, - }), - } as GeneratedObjectRecord & { __typename: string }; - }; - - return { - generateObjectRecordOptimisticResponse, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts index 7253e25f468f..edf234883af8 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useGetRecordFromCache.ts @@ -1,45 +1,37 @@ -import { gql, useApolloClient } from '@apollo/client'; +import { useCallback } from 'react'; +import { useApolloClient } from '@apollo/client'; +import { useRecoilValue } from 'recoil'; -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { getRecordFromCache } from '@/object-record/cache/utils/getRecordFromCache'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; export const useGetRecordFromCache = ({ - objectMetadataItem, + objectNameSingular, }: { - objectMetadataItem: ObjectMetadataItem; + objectNameSingular: string; }) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - const apolloClient = useApolloClient(); - - return ( - recordId: string, - cache = apolloClient.cache, - ) => { - if (!objectMetadataItem) { - return null; - } + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); - const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const cacheReadFragment = gql` - fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} { - id - ${objectMetadataItem.fields - .map((field) => mapFieldMetadataToGraphQLQuery({ field })) - .join('\n')} - } - `; - - const cachedRecordId = cache.identify({ - __typename: capitalize(objectMetadataItem.nameSingular), - id: recordId, - }); + const apolloClient = useApolloClient(); - return cache.readFragment({ - id: cachedRecordId, - fragment: cacheReadFragment, - }); - }; + return useCallback( + ( + recordId: string, + cache = apolloClient.cache, + ) => { + return getRecordFromCache({ + cache, + recordId, + objectMetadataItems, + objectMetadataItem, + }); + }, + [objectMetadataItem, objectMetadataItems, apolloClient], + ); }; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache.ts deleted file mode 100644 index 9e153f991394..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useInjectIntoFindOneRecordQueryCache.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useApolloClient } from '@apollo/client'; - -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useGenerateFindOneRecordQuery } from '@/object-record/hooks/useGenerateFindOneRecordQuery'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useInjectIntoFindOneRecordQueryCache = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const apolloClient = useApolloClient(); - - const generateFindOneRecordQuery = useGenerateFindOneRecordQuery(); - - const injectIntoFindOneRecordQueryCache = < - T extends ObjectRecord = ObjectRecord, - >( - record: T, - ) => { - const findOneRecordQueryForCacheInjection = generateFindOneRecordQuery({ - objectMetadataItem, - depth: 1, - }); - - apolloClient.writeQuery({ - query: findOneRecordQueryForCacheInjection, - variables: { - objectRecordId: record.id, - }, - data: { - [objectMetadataItem.nameSingular]: { - __typename: `${capitalize(objectMetadataItem.nameSingular)}`, - ...record, - }, - }, - }); - }; - - return { - injectIntoFindOneRecordQueryCache, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useModifyRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useModifyRecordFromCache.ts deleted file mode 100644 index 3d87f6d4ea58..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useModifyRecordFromCache.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useApolloClient } from '@apollo/client'; -import { Modifiers } from '@apollo/client/cache'; - -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useModifyRecordFromCache = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const { cache } = useApolloClient(); - - return ( - recordId: string, - fieldModifiers: Modifiers, - ) => { - if (!objectMetadataItem) return; - - const cachedRecordId = cache.identify({ - __typename: capitalize(objectMetadataItem.nameSingular), - id: recordId, - }); - - cache.modify({ - id: cachedRecordId, - fields: fieldModifiers, - }); - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useReadFindManyRecordsQueryInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useReadFindManyRecordsQueryInCache.ts index 56e7e09cdafd..405815034d4d 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useReadFindManyRecordsQueryInCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useReadFindManyRecordsQueryInCache.ts @@ -1,12 +1,13 @@ import { useApolloClient } from '@apollo/client'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; -import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordQueryResult } from '@/object-record/types/ObjectRecordQueryResult'; import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery'; +import { isDefined } from '~/utils/isDefined'; export const useReadFindManyRecordsQueryInCache = ({ objectMetadataItem, @@ -15,17 +16,24 @@ export const useReadFindManyRecordsQueryInCache = ({ }) => { const apolloClient = useApolloClient(); - const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery(); + const { objectMetadataItems } = useObjectMetadataItems(); const readFindManyRecordsQueryInCache = < T extends ObjectRecord = ObjectRecord, >({ queryVariables, + queryFields, + depth, }: { queryVariables: ObjectRecordQueryVariables; + queryFields?: Record; + depth?: number; }) => { const findManyRecordsQueryForCacheRead = generateFindManyRecordsQuery({ objectMetadataItem, + objectMetadataItems, + queryFields, + depth, }); const existingRecordsQueryResult = apolloClient.readQuery< @@ -38,7 +46,7 @@ export const useReadFindManyRecordsQueryInCache = ({ const existingRecordConnection = existingRecordsQueryResult?.[objectMetadataItem.namePlural]; - const existingObjectRecords = isNonNullable(existingRecordConnection) + const existingObjectRecords = isDefined(existingRecordConnection) ? getRecordsFromRecordConnection({ recordConnection: existingRecordConnection, }) diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts index 210ecee55c61..01be96226139 100644 --- a/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache.ts @@ -1,11 +1,13 @@ import { useApolloClient } from '@apollo/client'; +import { useRecoilValue } from 'recoil'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { MAX_QUERY_DEPTH_FOR_CACHE_INJECTION } from '@/object-record/cache/constants/MaxQueryDepthForCacheInjection'; import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; -import { useGenerateFindManyRecordsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsQuery'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; +import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery'; export const useUpsertFindManyRecordsQueryInCache = ({ objectMetadataItem, @@ -14,25 +16,37 @@ export const useUpsertFindManyRecordsQueryInCache = ({ }) => { const apolloClient = useApolloClient(); - const generateFindManyRecordsQuery = useGenerateFindManyRecordsQuery(); + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); const upsertFindManyRecordsQueryInCache = < T extends ObjectRecord = ObjectRecord, >({ queryVariables, + depth = MAX_QUERY_DEPTH_FOR_CACHE_INJECTION, objectRecordsToOverwrite, + queryFields, + computeReferences = false, }: { queryVariables: ObjectRecordQueryVariables; + depth?: number; objectRecordsToOverwrite: T[]; + queryFields?: Record; + computeReferences?: boolean; }) => { const findManyRecordsQueryForCacheOverwrite = generateFindManyRecordsQuery({ objectMetadataItem, - depth: MAX_QUERY_DEPTH_FOR_CACHE_INJECTION, // TODO: fix this + objectMetadataItems, + depth, + queryFields, + computeReferences, }); const newObjectRecordConnection = getRecordConnectionFromRecords({ - objectNameSingular: objectMetadataItem.nameSingular, + objectMetadataItems: objectMetadataItems, + objectMetadataItem: objectMetadataItem, records: objectRecordsToOverwrite, + queryFields, + computeReferences, }); apolloClient.writeQuery({ diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts new file mode 100644 index 000000000000..ec9ec8b3a4a3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/deleteRecordFromCache.ts @@ -0,0 +1,30 @@ +import { ApolloCache } from '@apollo/client'; + +import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +export const deleteRecordFromCache = ({ + objectMetadataItem, + objectMetadataItems, + recordToDelete, + cache, +}: { + objectMetadataItem: ObjectMetadataItem; + objectMetadataItems: ObjectMetadataItem[]; + recordToDelete: ObjectRecord; + cache: ApolloCache; +}) => { + triggerDeleteRecordsOptimisticEffect({ + cache, + objectMetadataItem, + objectMetadataItems, + recordsToDelete: [ + { + ...recordToDelete, + __typename: getObjectTypename(objectMetadataItem.nameSingular), + }, + ], + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getCacheReferenceFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getCacheReferenceFromRecord.ts deleted file mode 100644 index dd6e94295a9f..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getCacheReferenceFromRecord.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ApolloClient, makeReference, Reference } from '@apollo/client'; - -import { getCachedRecordFromRecord } from '@/object-record/cache/utils/getCachedRecordFromRecord'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; - -export const getCacheReferenceFromRecord = ({ - apolloClient, - objectNameSingular, - record, -}: { - apolloClient: ApolloClient; - objectNameSingular: string; - record: T; -}): Reference => { - const cachedRecord = getCachedRecordFromRecord({ - objectNameSingular, - record, - }); - - const id = apolloClient.cache.identify(cachedRecord); - - if (!id) { - throw new Error( - `Could not identify record "${objectNameSingular}", id : "${record.id}"`, - ); - } - - const recordReference = makeReference(id); - - return recordReference; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getCachedRecordEdgesFromRecords.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getCachedRecordEdgesFromRecords.ts deleted file mode 100644 index 11f1cec67583..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getCachedRecordEdgesFromRecords.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ApolloClient, makeReference } from '@apollo/client'; - -import { CachedObjectRecordEdge } from '@/apollo/types/CachedObjectRecordEdge'; -import { getCachedRecordFromRecord } from '@/object-record/cache/utils/getCachedRecordFromRecord'; -import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; - -export const getCachedRecordEdgesFromRecords = ({ - apolloClient, - objectNameSingular, - records, -}: { - apolloClient: ApolloClient; - objectNameSingular: string; - records: T[]; -}): CachedObjectRecordEdge[] => { - const cachedRecordEdges = records.map((record) => { - const cachedRecord = getCachedRecordFromRecord({ - objectNameSingular, - record, - }); - - const id = apolloClient.cache.identify(cachedRecord); - - if (!id) { - throw new Error( - `Could not identify record "${objectNameSingular}", id : "${record.id}"`, - ); - } - - const reference = makeReference(id); - - const cachedObjectRecordEdge: CachedObjectRecordEdge = { - cursor: '', - node: reference, - __typename: getEdgeTypename({ objectNameSingular }), - }; - - return cachedObjectRecordEdge; - }); - - return cachedRecordEdges; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getCachedRecordFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getCachedRecordFromRecord.ts deleted file mode 100644 index 31a72e8de475..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getCachedRecordFromRecord.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; -import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; - -export const getCachedRecordFromRecord = ({ - objectNameSingular, - record, -}: { - objectNameSingular: string; - record: T; -}): CachedObjectRecord => { - return { - __typename: getNodeTypename({ objectNameSingular }), - ...record, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getConnectionTypename.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getConnectionTypename.ts index 6c2139148fc9..7b827c2ba1da 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getConnectionTypename.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getConnectionTypename.ts @@ -1,9 +1,6 @@ +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { capitalize } from '~/utils/string/capitalize'; -export const getConnectionTypename = ({ - objectNameSingular, -}: { - objectNameSingular: string; -}) => { - return `${capitalize(objectNameSingular)}Connection`; +export const getConnectionTypename = (objectNameSingular: string) => { + return `${capitalize(getObjectTypename(objectNameSingular))}Connection`; }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getEdgeTypename.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getEdgeTypename.ts index da024846a995..f2cd62ff4c16 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getEdgeTypename.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getEdgeTypename.ts @@ -1,9 +1,6 @@ +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { capitalize } from '~/utils/string/capitalize'; -export const getEdgeTypename = ({ - objectNameSingular, -}: { - objectNameSingular: string; -}) => { - return `${capitalize(objectNameSingular)}Edge`; +export const getEdgeTypename = (objectNameSingular: string) => { + return `${capitalize(getObjectTypename(objectNameSingular))}Edge`; }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getNodeTypename.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getNodeTypename.ts index c058a5349763..16d3122c34b6 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getNodeTypename.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getNodeTypename.ts @@ -1,9 +1,6 @@ +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; import { capitalize } from '~/utils/string/capitalize'; -export const getNodeTypename = ({ - objectNameSingular, -}: { - objectNameSingular: string; -}) => { - return capitalize(objectNameSingular); +export const getNodeTypename = (objectNameSingular: string) => { + return capitalize(getObjectTypename(objectNameSingular)); }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getObjectTypename.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getObjectTypename.ts new file mode 100644 index 000000000000..7a799bf9d2ba --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getObjectTypename.ts @@ -0,0 +1,5 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getObjectTypename = (objectNameSingular: string) => { + return capitalize(objectNameSingular); +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromEdges.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromEdges.ts deleted file mode 100644 index f43ce56e008d..000000000000 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromEdges.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename'; -import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; - -export const getRecordConnectionFromEdges = ({ - objectNameSingular, - edges, -}: { - objectNameSingular: string; - edges: ObjectRecordEdge[]; -}) => { - return { - __typename: getConnectionTypename({ objectNameSingular }), - edges: edges, - pageInfo: getEmptyPageInfo(), - } as ObjectRecordConnection; -}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts index affe038e996e..70090d490c3a 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordConnectionFromRecords.ts @@ -1,3 +1,4 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getConnectionTypename } from '@/object-record/cache/utils/getConnectionTypename'; import { getEmptyPageInfo } from '@/object-record/cache/utils/getEmptyPageInfo'; import { getRecordEdgeFromRecord } from '@/object-record/cache/utils/getRecordEdgeFromRecord'; @@ -5,21 +6,41 @@ import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; export const getRecordConnectionFromRecords = ({ - objectNameSingular, + objectMetadataItems, + objectMetadataItem, records, + queryFields, + withPageInfo = true, + computeReferences = false, + isRootLevel = true, + depth = 1, }: { - objectNameSingular: string; + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'namePlural' | 'nameSingular' + >; records: T[]; + queryFields?: Record; + withPageInfo?: boolean; + isRootLevel?: boolean; + computeReferences?: boolean; + depth?: number; }) => { return { - __typename: getConnectionTypename({ objectNameSingular }), + __typename: getConnectionTypename(objectMetadataItem.nameSingular), edges: records.map((record) => { return getRecordEdgeFromRecord({ - objectNameSingular, + objectMetadataItems, + objectMetadataItem, + queryFields, record, + isRootLevel, + computeReferences, + depth, }); }), - pageInfo: getEmptyPageInfo(), - totalCount: records.length, + ...(withPageInfo && { pageInfo: getEmptyPageInfo() }), + ...(withPageInfo && { totalCount: records.length }), } as ObjectRecordConnection; }; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordEdgeFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordEdgeFromRecord.ts index 8921edae21e7..7327e2e169e3 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordEdgeFromRecord.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordEdgeFromRecord.ts @@ -1,20 +1,40 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getEdgeTypename } from '@/object-record/cache/utils/getEdgeTypename'; -import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; +import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; export const getRecordEdgeFromRecord = ({ - objectNameSingular, + objectMetadataItems, + objectMetadataItem, + queryFields, record, + computeReferences = false, + isRootLevel = false, }: { - objectNameSingular: string; + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'namePlural' | 'nameSingular' + >; + queryFields?: Record; + computeReferences?: boolean; + isRootLevel?: boolean; + depth?: number; record: T; }) => { return { - __typename: getEdgeTypename({ objectNameSingular }), + __typename: getEdgeTypename(objectMetadataItem.nameSingular), node: { - __typename: getNodeTypename({ objectNameSingular }), - ...record, + ...getRecordNodeFromRecord({ + objectMetadataItems, + objectMetadataItem, + queryFields, + record, + computeReferences, + isRootLevel, + depth: 1, + }), }, cursor: '', } as ObjectRecordEdge; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts new file mode 100644 index 000000000000..5db9a5e65f0e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromCache.ts @@ -0,0 +1,55 @@ +import { ApolloCache, gql } from '@apollo/client'; + +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const getRecordFromCache = ({ + objectMetadataItem, + objectMetadataItems, + cache, + recordId, +}: { + cache: ApolloCache; + recordId: string; + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: ObjectMetadataItem; +}) => { + if (isUndefinedOrNull(objectMetadataItem)) { + return null; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const cacheReadFragment = gql` + fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery( + { + objectMetadataItems, + objectMetadataItem, + }, + )} + `; + + const cachedRecordId = cache.identify({ + __typename: capitalize(objectMetadataItem.nameSingular), + id: recordId, + }); + + const record = cache.readFragment({ + id: cachedRecordId, + fragment: cacheReadFragment, + returnPartialData: true, + }); + + if (isUndefinedOrNull(record)) { + return null; + } + + return getRecordFromRecordNode({ + recordNode: record, + }) as CachedObjectRecord; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts new file mode 100644 index 000000000000..9a7d59538818 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordFromRecordNode.ts @@ -0,0 +1,36 @@ +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +export const getRecordFromRecordNode = ({ + recordNode, +}: { + recordNode: T; +}): T => { + return { + ...Object.fromEntries( + Object.entries(recordNode).map(([fieldName, value]) => { + if (isUndefinedOrNull(value)) { + return [fieldName, value]; + } + + if (Array.isArray(value)) { + return [fieldName, value]; + } + + if (typeof value !== 'object') { + return [fieldName, value]; + } + + return isDefined(value.edges) + ? [ + fieldName, + getRecordsFromRecordConnection({ recordConnection: value }), + ] + : [fieldName, getRecordFromRecordNode({ recordNode: value })]; + }), + ), + id: recordNode.id, + } as T; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts new file mode 100644 index 000000000000..886cbd6b93a0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordNodeFromRecord.ts @@ -0,0 +1,166 @@ +import { isNull, isUndefined } from '@sniptt/guards'; + +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getNodeTypename } from '@/object-record/cache/utils/getNodeTypename'; +import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename'; +import { getRecordConnectionFromRecords } from '@/object-record/cache/utils/getRecordConnectionFromRecords'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { + FieldMetadataType, + RelationDefinitionType, +} from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; +import { lowerAndCapitalize } from '~/utils/string/lowerAndCapitalize'; + +export const getRecordNodeFromRecord = ({ + objectMetadataItems, + objectMetadataItem, + queryFields, + record, + computeReferences = true, + isRootLevel = true, + depth = 1, +}: { + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: Pick< + ObjectMetadataItem, + 'fields' | 'namePlural' | 'nameSingular' + >; + queryFields?: Record; + computeReferences?: boolean; + isRootLevel?: boolean; + record: T | null; + depth?: number; +}) => { + if (isNull(record)) { + return null; + } + + const nodeTypeName = getNodeTypename(objectMetadataItem.nameSingular); + + if (!isRootLevel && computeReferences) { + return { + __ref: `${nodeTypeName}:${record.id}`, + } as unknown as CachedObjectRecord; // Todo Fix typing + } + + const nestedRecord = Object.fromEntries( + Object.entries(record) + .map(([fieldName, value]) => { + if (isDefined(queryFields) && !queryFields[fieldName]) { + return undefined; + } + + const field = objectMetadataItem.fields.find( + (field) => field.name === fieldName, + ); + + if (isUndefined(field)) { + return undefined; + } + + if ( + !isUndefined(depth) && + depth < 1 && + field.type === FieldMetadataType.Relation + ) { + return undefined; + } + + if ( + field.type === FieldMetadataType.Relation && + field.relationDefinition?.direction === + RelationDefinitionType.OneToMany + ) { + const oneToManyObjectMetadataItem = objectMetadataItems.find( + (item) => item.namePlural === fieldName, + ); + + if (!oneToManyObjectMetadataItem) { + return undefined; + } + + return [ + fieldName, + getRecordConnectionFromRecords({ + objectMetadataItems, + objectMetadataItem: oneToManyObjectMetadataItem, + records: value as ObjectRecord[], + queryFields: + queryFields?.[fieldName] === true || + isUndefined(queryFields?.[fieldName]) + ? undefined + : queryFields?.[fieldName], + withPageInfo: false, + isRootLevel: false, + computeReferences, + depth: depth - 1, + }), + ]; + } + + switch (field.type) { + case FieldMetadataType.Relation: { + if ( + isUndefined( + field.relationDefinition?.targetObjectMetadata.nameSingular, + ) + ) { + return undefined; + } + + if (isNull(value)) { + return [fieldName, null]; + } + + if (isUndefined(value?.id)) { + return undefined; + } + + const typeName = getObjectTypename( + field.relationDefinition?.targetObjectMetadata.nameSingular, + ); + + if (computeReferences) { + return [ + fieldName, + { + __ref: `${typeName}:${value.id}`, + }, + ]; + } + + return [ + fieldName, + { + __typename: typeName, + ...value, + }, + ]; + } + case FieldMetadataType.Link: + case FieldMetadataType.Address: + case FieldMetadataType.FullName: + case FieldMetadataType.Currency: { + return [ + fieldName, + { + ...value, + __typename: lowerAndCapitalize(field.type), + }, + ]; + } + default: { + return [fieldName, value]; + } + } + }) + .filter(isDefined), + ) as T; // Todo fix typing once we have investigated apollo edges / nodes removal + + return { + __typename: getNodeTypename(objectMetadataItem.nameSingular), + ...nestedRecord, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts index f52d8e0fb7b5..c427bd0559c4 100644 --- a/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/cache/utils/getRecordsFromRecordConnection.ts @@ -1,3 +1,4 @@ +import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; @@ -6,5 +7,7 @@ export const getRecordsFromRecordConnection = ({ }: { recordConnection: ObjectRecordConnection; }): T[] => { - return recordConnection.edges.map((edge) => edge.node); + return recordConnection.edges.map((edge) => + getRecordFromRecordNode({ recordNode: edge.node }), + ); }; diff --git a/packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts similarity index 100% rename from packages/twenty-front/src/modules/apollo/optimistic-effect/utils/isObjectRecordConnection.ts rename to packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnection.ts diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnectionWithRefs.ts b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnectionWithRefs.ts new file mode 100644 index 000000000000..a893901960f5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/isObjectRecordConnectionWithRefs.ts @@ -0,0 +1,30 @@ +import { StoreValue } from '@apollo/client'; +import { z } from 'zod'; + +import { CachedObjectRecordConnection } from '@/apollo/types/CachedObjectRecordConnection'; +import { capitalize } from '~/utils/string/capitalize'; + +export const isObjectRecordConnectionWithRefs = ( + objectNameSingular: string, + storeValue: StoreValue, +): storeValue is CachedObjectRecordConnection => { + const objectConnectionTypeName = `${capitalize( + objectNameSingular, + )}Connection`; + const objectEdgeTypeName = `${capitalize(objectNameSingular)}Edge`; + const cachedObjectConnectionSchema = z.object({ + __typename: z.literal(objectConnectionTypeName), + edges: z.array( + z.object({ + __typename: z.literal(objectEdgeTypeName), + node: z.object({ + __ref: z.string().startsWith(`${capitalize(objectNameSingular)}:`), + }), + }), + ), + }); + const cachedConnectionValidation = + cachedObjectConnectionSchema.safeParse(storeValue); + + return cachedConnectionValidation.success; +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/modifyRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/modifyRecordFromCache.ts new file mode 100644 index 000000000000..3100edffa6d8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/modifyRecordFromCache.ts @@ -0,0 +1,33 @@ +import { ApolloCache, Modifiers } from '@apollo/client/cache'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const modifyRecordFromCache = < + CachedObjectRecord extends ObjectRecord = ObjectRecord, +>({ + objectMetadataItem, + cache, + fieldModifiers, + recordId, +}: { + objectMetadataItem: ObjectMetadataItem; + cache: ApolloCache; + fieldModifiers: Modifiers; + recordId: string; +}) => { + if (isUndefinedOrNull(objectMetadataItem)) return; + + const cachedRecordId = cache.identify({ + __typename: capitalize(objectMetadataItem.nameSingular), + id: recordId, + }); + + cache.modify({ + id: cachedRecordId, + fields: fieldModifiers, + optimistic: true, + }); +}; diff --git a/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts b/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts new file mode 100644 index 000000000000..15636bc744d2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/utils/updateRecordFromCache.ts @@ -0,0 +1,59 @@ +import { ApolloCache } from '@apollo/client/cache'; +import gql from 'graphql-tag'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const updateRecordFromCache = ({ + objectMetadataItems, + objectMetadataItem, + cache, + record, +}: { + objectMetadataItems: ObjectMetadataItem[]; + objectMetadataItem: ObjectMetadataItem; + cache: ApolloCache; + record: T; +}) => { + if (isUndefinedOrNull(objectMetadataItem)) { + return null; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const cacheWriteFragment = gql` + fragment ${capitalizedObjectName}Fragment on ${capitalizedObjectName} ${mapObjectMetadataToGraphQLQuery( + { + objectMetadataItems, + objectMetadataItem, + computeReferences: true, + }, + )} + `; + + const cachedRecordId = cache.identify({ + __typename: capitalize(objectMetadataItem.nameSingular), + id: record.id, + }); + + const recordWithConnection = getRecordNodeFromRecord({ + objectMetadataItems, + objectMetadataItem, + record, + depth: 1, + }); + + if (isUndefinedOrNull(recordWithConnection)) { + return; + } + + cache.writeFragment({ + id: cachedRecordId, + fragment: cacheWriteFragment, + data: recordWithConnection, + }); +}; 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 0da8ecf3b793..20ef7dd1eaf9 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { EntityChip } from '@/ui/display/chip/components/EntityChip'; @@ -18,14 +17,10 @@ export const RecordChip = ({ maxWidth, className, }: RecordChipProps) => { - const { objectMetadataItem } = useObjectMetadataItemOnly({ + const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ objectNameSingular, }); - const mapToObjectRecordIdentifier = useMapToObjectRecordIdentifier({ - objectMetadataItem, - }); - const objectRecordIdentifier = mapToObjectRecordIdentifier(record); return ( diff --git a/packages/twenty-front/src/modules/object-record/constants/EmptyMutation.ts b/packages/twenty-front/src/modules/object-record/constants/EmptyMutation.ts new file mode 100644 index 000000000000..21a255ac56f3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/constants/EmptyMutation.ts @@ -0,0 +1,7 @@ +import gql from 'graphql-tag'; + +export const EMPTY_MUTATION = gql` + mutation EmptyMutation { + empty + } +`; diff --git a/packages/twenty-front/src/modules/object-record/constants/EmptyQuery.ts b/packages/twenty-front/src/modules/object-record/constants/EmptyQuery.ts new file mode 100644 index 000000000000..e046ec1726ad --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/constants/EmptyQuery.ts @@ -0,0 +1,7 @@ +import gql from 'graphql-tag'; + +export const EMPTY_QUERY = gql` + query EmptyQuery { + empty + } +`; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts index 7fd08fc2e7ea..87cab48baaa6 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateManyRecords.ts @@ -5,72 +5,28 @@ import { Person } from '@/people/types/Person'; export const query = gql` mutation CreatePeople($data: [PersonCreateInput!]!) { createPeople(data: $data) { - id - opportunities { - edges { - node { - __typename - id - } + __typename + xLink { + label + url } - } - xLink { - label - url - } - id - pointOfContactForOpportunities { - edges { - node { - __typename - id - } - } - } - createdAt - company { - __typename id - } - city - email - activityTargets { - edges { - node { - __typename - id - } - } - } - jobTitle - favorites { - edges { - node { - __typename - id - } + createdAt + city + email + jobTitle + name { + firstName + lastName } - } - attachments { - edges { - node { - __typename - id - } + phone + linkedinLink { + label + url } - } - name { - firstName - lastName - } - phone - linkedinLink { - label - url - } - updatedAt - avatarUrl - companyId + updatedAt + avatarUrl + companyId } } `; @@ -86,32 +42,15 @@ const data = [ export const variables = { data }; export const responseData = { - opportunities: { - edges: [], - }, + __typeName: '', xLink: { label: '', url: '', }, - pointOfContactForOpportunities: { - edges: [], - }, createdAt: '', - company: { - id: '', - }, city: '', email: '', - activityTargets: { - edges: [], - }, jobTitle: '', - favorites: { - edges: [], - }, - attachments: { - edges: [], - }, name: { firstName: '', lastName: '', diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts index b7a2ebc0e359..764a78610248 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useCreateOneRecord.ts @@ -3,72 +3,28 @@ import { gql } from '@apollo/client'; export const query = gql` mutation CreateOnePerson($input: PersonCreateInput!) { createPerson(data: $input) { - id - opportunities { - edges { - node { - __typename - id - } + __typename + xLink { + label + url } - } - xLink { - label - url - } - id - pointOfContactForOpportunities { - edges { - node { - __typename - id - } - } - } - createdAt - company { - __typename id - } - city - email - activityTargets { - edges { - node { - __typename - id - } - } - } - jobTitle - favorites { - edges { - node { - __typename - id - } + createdAt + city + email + jobTitle + name { + firstName + lastName } - } - attachments { - edges { - node { - __typename - id - } + phone + linkedinLink { + label + url } - } - name { - firstName - lastName - } - phone - linkedinLink { - label - url - } - updatedAt - avatarUrl - companyId + updatedAt + avatarUrl + companyId } } `; 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 f31b210e089b..d0110d92f9d4 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 @@ -1,7 +1,7 @@ import { gql } from '@apollo/client'; export const query = gql` - mutation DeleteOnePerson($idToDelete: ID!) { + mutation DeleteOnePerson($idToDelete: UUID!) { deletePerson(id: $idToDelete) { id } diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useExecuteQuickActionOnOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useExecuteQuickActionOnOneRecord.ts index 343e3e59e5c1..7f096be5f02a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useExecuteQuickActionOnOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useExecuteQuickActionOnOneRecord.ts @@ -3,74 +3,30 @@ import { gql } from '@apollo/client'; export { responseData } from './useUpdateOneRecord'; export const query = gql` - mutation ExecuteQuickActionOnOnePerson($idToExecuteQuickActionOn: ID!) { + mutation ExecuteQuickActionOnOnePerson($idToExecuteQuickActionOn: UUID!) { executeQuickActionOnPerson(id: $idToExecuteQuickActionOn) { - id - opportunities { - edges { - node { - __typename - id - } + __typename + xLink { + label + url } - } - xLink { - label - url - } - id - pointOfContactForOpportunities { - edges { - node { - __typename - id - } - } - } - createdAt - company { - __typename id - } - city - email - activityTargets { - edges { - node { - __typename - id - } - } - } - jobTitle - favorites { - edges { - node { - __typename - id - } + createdAt + city + email + jobTitle + name { + firstName + lastName } - } - attachments { - edges { - node { - __typename - id - } + phone + linkedinLink { + label + url } - } - name { - firstName - lastName - } - phone - linkedinLink { - label - url - } - updatedAt - avatarUrl - companyId + updatedAt + avatarUrl + companyId } } `; 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 84eb553d97e9..7a756cac76ef 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 @@ -26,7 +26,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { @@ -52,7 +52,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts index d5695be62d95..d0907398b836 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindOneRecord.ts @@ -3,74 +3,30 @@ import { gql } from '@apollo/client'; import { responseData as person } from './useUpdateOneRecord'; export const query = gql` - query FindOneperson($objectRecordId: UUID!) { + query FindOnePerson($objectRecordId: UUID!) { person(filter: { id: { eq: $objectRecordId } }) { - id - opportunities { - edges { - node { - __typename - id + __typename + xLink { + label + url } - } - } - xLink { - label - url - } - id - pointOfContactForOpportunities { - edges { - node { - __typename - id + id + createdAt + city + email + jobTitle + name { + firstName + lastName } - } - } - createdAt - company { - __typename - id - } - city - email - activityTargets { - edges { - node { - __typename - id + phone + linkedinLink { + label + url } - } - } - jobTitle - favorites { - edges { - node { - __typename - id - } - } - } - attachments { - edges { - node { - __typename - id - } - } - } - name { - firstName - lastName - } - phone - linkedinLink { - label - url - } - updatedAt - avatarUrl - companyId + updatedAt + avatarUrl + companyId } } `; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useMapConnectionToRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useMapConnectionToRecords.ts deleted file mode 100644 index 847b5c0e6cd4..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useMapConnectionToRecords.ts +++ /dev/null @@ -1,783 +0,0 @@ -import { Company } from '@/companies/types/Company'; -import { Favorite } from '@/favorites/types/Favorite'; -import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { Person } from '@/people/types/Person'; - -export const emptyConnectionMock: ObjectRecordConnection = { - edges: [], - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - startCursor: '', - endCursor: '', - }, - totalCount: 0, - __typename: 'ObjectRecordConnection', -}; - -export const companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock: ObjectRecordConnection< - Partial & - Pick & { - people: ObjectRecordConnection< - Pick & { - favorites: ObjectRecordConnection< - Pick - >; - } - >; - } -> = { - pageInfo: { - endCursor: 'WyJmZTI1NmIzOS0zZWMzLTRmZTMtODk5Ny1iNzZhYTBiZmE0MDgiXQ==', - hasNextPage: true, - hasPreviousPage: false, - startCursor: 'WyIwNGIyZTlmNS0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - }, - edges: [ - { - cursor: 'WyIwNGIyZTlmNS0wNzEzLTQwYTUtODIxNi04MjgwMjQwMWQzM2UiXQ==', - node: { - id: '04b2e9f5-0713-40a5-8216-82802401d33e', - name: 'Qonto', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyIwZDk0MDk5Ny1jMjFlLTRlYzItODczYi1kZTQyNjRkODkwMjUiXQ==', - node: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - people: { - edges: [ - { - cursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049190df', - name: { - firstName: 'Bertrand', - lastName: 'Voulzy', - }, - favorites: { - edges: [ - { - cursor: - 'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==', - node: { - id: 'c85a867c-5a8f-4861-8ed2-96c390248423', - personId: '240da2ec-2d40-4e49-8df4-9c6a049190df', - companyId: null, - position: 2, - }, - }, - ], - pageInfo: { - endCursor: - 'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyJjODVhODY3Yy01YThmLTQ4NjEtOGVkMi05NmMzOTAyNDg0MjMiXQ==', - }, - totalCount: 1, - }, - }, - }, - { - cursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZWYiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049190ef', - name: { - firstName: 'Madison', - lastName: 'Perez', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyI1Njk1NTQyMi01ZDU0LTQxYjctYmEzNi1mMGQyMGUxNDE3YWUiXQ==', - node: { - id: '56955422-5d54-41b7-ba36-f0d20e1417ae', - name: { - firstName: 'Avery', - lastName: 'Carter', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyI3NTUwMzVkYi02MjNkLTQxZmUtOTJlNy1kZDQ1YjdjNTY4ZTEiXQ==', - node: { - id: '755035db-623d-41fe-92e7-dd45b7c568e1', - name: { - firstName: 'Ethan', - lastName: 'Mitchell', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyJhMmU3OGE1Zi0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==', - node: { - id: 'a2e78a5f-338b-46df-8811-fa08c7d19d35', - name: { - firstName: 'Elizabeth', - lastName: 'Baker', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', - node: { - id: 'ca1f5bf3-64ad-4b0e-bbfd-e9fd795b7016', - name: { - firstName: 'Christopher', - lastName: 'Nelson', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - ], - pageInfo: { - endCursor: - 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==', - }, - totalCount: 6, - }, - }, - }, - { - cursor: 'WyIxMTg5OTVmMy01ZDgxLTQ2ZDYtYmY4My1mN2ZkMzNlYTYxMDIiXQ==', - node: { - id: '118995f3-5d81-46d6-bf83-f7fd33ea6102', - name: 'Facebook', - people: { - edges: [ - { - cursor: - 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', - node: { - id: '93c72d2e-f517-42fd-80ae-14173b3b70ae', - name: { - firstName: 'Christopher', - lastName: 'Gonzalez', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', - node: { - id: 'eeeacacf-eee1-4690-ad2c-8619e5b56a2e', - name: { - firstName: 'Ashley', - lastName: 'Parker', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - ], - pageInfo: { - endCursor: - 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', - }, - totalCount: 2, - }, - }, - }, - { - cursor: 'WyIxZDNhMWM2ZS03MDdlLTQ0ZGMtYTFkMi0zMDAzMGJmMWE5NDQiXQ==', - node: { - id: '1d3a1c6e-707e-44dc-a1d2-30030bf1a944', - name: 'Netflix', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyI0NjBiNmZiMS1lZDg5LTQxM2EtYjMxYS05NjI5ODZlNjdiYjQiXQ==', - node: { - id: '460b6fb1-ed89-413a-b31a-962986e67bb4', - name: 'Microsoft', - people: { - edges: [ - { - cursor: - 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', - node: { - id: '1d151852-490f-4466-8391-733cfd66a0c8', - name: { - firstName: 'Isabella', - lastName: 'Scott', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyI5ODQwNmUyNi04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==', - node: { - id: '98406e26-80f1-4dff-b570-a74942528de3', - name: { - firstName: 'Matthew', - lastName: 'Green', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: - 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', - node: { - id: '9b324a88-6784-4449-afdf-dc62cb8702f2', - name: { - firstName: 'Nicholas', - lastName: 'Wright', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - ], - pageInfo: { - endCursor: - 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', - }, - totalCount: 3, - }, - }, - }, - { - cursor: 'WyI3YTkzZDFlNS0zZjc0LTQ5MmQtYTEwMS0yYTcwZjUwYTE2NDUiXQ==', - node: { - id: '7a93d1e5-3f74-492d-a101-2a70f50a1645', - name: 'Libeo', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyI4OWJiODI1Yy0xNzFlLTRiY2MtOWNmNy00MzQ0OGQ2ZmIyNzgiXQ==', - node: { - id: '89bb825c-171e-4bcc-9cf7-43448d6fb278', - name: 'Airbnb', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyI5ZDE2MmRlNi1jZmJmLTQxNTYtYTc5MC1lMzk4NTRkY2Q0ZWIiXQ==', - node: { - id: '9d162de6-cfbf-4156-a790-e39854dcd4eb', - name: 'Claap', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyJhNjc0ZmE2Yy0xNDU1LTRjNTctYWZhZi1kZDVkYzA4NjM2MWQiXQ==', - node: { - id: 'a674fa6c-1455-4c57-afaf-dd5dc086361d', - name: 'Algolia', - people: { - edges: [ - { - cursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049191df', - name: { - firstName: 'Lorie', - lastName: 'Vladim', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - ], - pageInfo: { - endCursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', - }, - totalCount: 1, - }, - }, - }, - { - cursor: 'WyJhN2JjNjhkNS1mNzllLTQwZGQtYmQwNi1jMzZlNmFiYjQ2NzgiXQ==', - node: { - id: 'a7bc68d5-f79e-40dd-bd06-c36e6abb4678', - name: 'Samsung', - people: { - edges: [ - { - cursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049191de', - name: { - firstName: 'Louis', - lastName: 'Duss', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - ], - pageInfo: { - endCursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', - }, - totalCount: 1, - }, - }, - }, - { - cursor: 'WyJhYWZmY2ZiZC1mODZiLTQxOWYtYjc5NC0wMjMxOWFiZTg2MzciXQ==', - node: { - id: 'aaffcfbd-f86b-419f-b794-02319abe8637', - name: 'Hasura', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyJmMzNkYzI0Mi01NTE4LTQ1NTMtOTQzMy00MmQ4ZWI4MjgzNGIiXQ==', - node: { - id: 'f33dc242-5518-4553-9433-42d8eb82834b', - name: 'Wework', - people: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - { - cursor: 'WyJmZTI1NmIzOS0zZWMzLTRmZTMtODk5Ny1iNzZhYTBiZmE0MDgiXQ==', - node: { - id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', - name: 'Linkedin', - people: { - edges: [ - { - cursor: - 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', - node: { - id: '0aa00beb-ac73-4797-824e-87a1f5aea9e0', - name: { - firstName: 'Sylvie', - lastName: 'Palmer', - }, - favorites: { - edges: [ - { - cursor: - 'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==', - node: { - id: '37b97140-26b9-498c-837b-4f3de499ad83', - personId: '0aa00beb-ac73-4797-824e-87a1f5aea9e0', - companyId: null, - position: 1, - }, - }, - ], - pageInfo: { - endCursor: - 'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyIzN2I5NzE0MC0yNmI5LTQ5OGMtODM3Yi00ZjNkZTQ5OWFkODMiXQ==', - }, - totalCount: 1, - }, - }, - }, - { - cursor: - 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', - node: { - id: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', - name: { - firstName: 'Christoph', - lastName: 'Callisto', - }, - favorites: { - edges: [], - pageInfo: { - endCursor: null, - hasNextPage: false, - hasPreviousPage: false, - startCursor: null, - }, - totalCount: 0, - }, - }, - }, - ], - pageInfo: { - endCursor: - 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: - 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', - }, - totalCount: 2, - }, - }, - }, - ], - totalCount: 13, -}; - -export const peopleWithTheirUniqueCompanies: ObjectRecordConnection< - Pick & { company: Pick } -> = { - pageInfo: { - endCursor: 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', - hasNextPage: false, - hasPreviousPage: false, - startCursor: 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', - }, - totalCount: 15, - edges: [ - { - cursor: 'WyIwYWEwMGJlYi1hYzczLTQ3OTctODI0ZS04N2ExZjVhZWE5ZTAiXQ==', - node: { - id: '0aa00beb-ac73-4797-824e-87a1f5aea9e0', - company: { - id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', - name: 'Linkedin', - }, - }, - }, - { - cursor: 'WyIxZDE1MTg1Mi00OTBmLTQ0NjYtODM5MS03MzNjZmQ2NmEwYzgiXQ==', - node: { - id: '1d151852-490f-4466-8391-733cfd66a0c8', - company: { - id: '460b6fb1-ed89-413a-b31a-962986e67bb4', - name: 'Microsoft', - }, - }, - }, - { - cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZGYiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049190df', - company: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - }, - }, - }, - { - cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkwZWYiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049190ef', - company: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - }, - }, - }, - { - cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGUiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049191de', - company: { - id: 'a7bc68d5-f79e-40dd-bd06-c36e6abb4678', - name: 'Samsung', - }, - }, - }, - { - cursor: 'WyIyNDBkYTJlYy0yZDQwLTRlNDktOGRmNC05YzZhMDQ5MTkxZGYiXQ==', - node: { - id: '240da2ec-2d40-4e49-8df4-9c6a049191df', - company: { - id: 'a674fa6c-1455-4c57-afaf-dd5dc086361d', - name: 'Algolia', - }, - }, - }, - { - cursor: 'WyI1Njk1NTQyMi01ZDU0LTQxYjctYmEzNi1mMGQyMGUxNDE3YWUiXQ==', - node: { - id: '56955422-5d54-41b7-ba36-f0d20e1417ae', - company: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - }, - }, - }, - { - cursor: 'WyI3NTUwMzVkYi02MjNkLTQxZmUtOTJlNy1kZDQ1YjdjNTY4ZTEiXQ==', - node: { - id: '755035db-623d-41fe-92e7-dd45b7c568e1', - company: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - }, - }, - }, - { - cursor: 'WyI4NjA4MzE0MS0xYzBlLTQ5NGMtYTFiNi04NWIxYzZmZWZhYTUiXQ==', - node: { - id: '86083141-1c0e-494c-a1b6-85b1c6fefaa5', - company: { - id: 'fe256b39-3ec3-4fe3-8997-b76aa0bfa408', - name: 'Linkedin', - }, - }, - }, - { - cursor: 'WyI5M2M3MmQyZS1mNTE3LTQyZmQtODBhZS0xNDE3M2IzYjcwYWUiXQ==', - node: { - id: '93c72d2e-f517-42fd-80ae-14173b3b70ae', - company: { - id: '118995f3-5d81-46d6-bf83-f7fd33ea6102', - name: 'Facebook', - }, - }, - }, - { - cursor: 'WyI5ODQwNmUyNi04MGYxLTRkZmYtYjU3MC1hNzQ5NDI1MjhkZTMiXQ==', - node: { - id: '98406e26-80f1-4dff-b570-a74942528de3', - company: { - id: '460b6fb1-ed89-413a-b31a-962986e67bb4', - name: 'Microsoft', - }, - }, - }, - { - cursor: 'WyI5YjMyNGE4OC02Nzg0LTQ0NDktYWZkZi1kYzYyY2I4NzAyZjIiXQ==', - node: { - id: '9b324a88-6784-4449-afdf-dc62cb8702f2', - company: { - id: '460b6fb1-ed89-413a-b31a-962986e67bb4', - name: 'Microsoft', - }, - }, - }, - { - cursor: 'WyJhMmU3OGE1Zi0zMzhiLTQ2ZGYtODgxMS1mYTA4YzdkMTlkMzUiXQ==', - node: { - id: 'a2e78a5f-338b-46df-8811-fa08c7d19d35', - company: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - }, - }, - }, - { - cursor: 'WyJjYTFmNWJmMy02NGFkLTRiMGUtYmJmZC1lOWZkNzk1YjcwMTYiXQ==', - node: { - id: 'ca1f5bf3-64ad-4b0e-bbfd-e9fd795b7016', - company: { - id: '0d940997-c21e-4ec2-873b-de4264d89025', - name: 'Google', - }, - }, - }, - { - cursor: 'WyJlZWVhY2FjZi1lZWUxLTQ2OTAtYWQyYy04NjE5ZTViNTZhMmUiXQ==', - node: { - id: 'eeeacacf-eee1-4690-ad2c-8619e5b56a2e', - company: { - id: '118995f3-5d81-46d6-bf83-f7fd33ea6102', - name: 'Facebook', - }, - }, - }, - ], -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateOneRecord.ts index e78e9e27b8d7..6b86c1a9f70c 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useUpdateOneRecord.ts @@ -1,74 +1,30 @@ import { gql } from '@apollo/client'; export const query = gql` - mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) { + mutation UpdateOnePerson($idToUpdate: UUID!, $input: PersonUpdateInput!) { updatePerson(id: $idToUpdate, data: $input) { - id - opportunities { - edges { - node { - __typename - id - } + __typename + xLink { + label + url } - } - xLink { - label - url - } - id - pointOfContactForOpportunities { - edges { - node { - __typename - id - } - } - } - createdAt - company { - __typename id - } - city - email - activityTargets { - edges { - node { - __typename - id - } - } - } - jobTitle - favorites { - edges { - node { - __typename - id - } + createdAt + city + email + jobTitle + name { + firstName + lastName } - } - attachments { - edges { - node { - __typename - id - } + phone + linkedinLink { + label + url } - } - name { - firstName - lastName - } - phone - linkedinLink { - label - url - } - updatedAt - avatarUrl - companyId + updatedAt + avatarUrl + companyId } } `; @@ -127,6 +83,6 @@ export const variables = { }; export const responseData = { - ...basePerson, + ...{ ...basePerson, __typename: 'Person' }, ...connectedObjects, }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx index 58a9e3fc221e..837af4dd2bf6 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useCreateOneRecord.test.tsx @@ -53,7 +53,6 @@ describe('useCreateOneRecord', () => { await act(async () => { const res = await result.current.createOneRecord(input); - console.log('res', res); expect(res).toBeDefined(); expect(res).toHaveProperty('id', personId); }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx index 480e74bab650..57e18be23cba 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useFindManyRecords.test.tsx @@ -84,14 +84,5 @@ describe('useFindManyRecords', () => { expect(result.current.loading).toBe(true); expect(result.current.error).toBeUndefined(); expect(result.current.records.length).toBe(0); - - // FATAL ERROR: Ineffective mark-compacts near heap limit Allocation failed - JavaScript heap out of memory - // await waitFor(() => { - // expect(result.current.loading).toBe(false); - // expect(result.current.records).toBeDefined(); - - // console.log({ res: result.current.records }); - // expect(result.current.records.length > 0).toBe(true); - // }); }); }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.test.tsx index 2b1ef39614ea..df7c904261a4 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.test.tsx @@ -3,7 +3,7 @@ import { renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; const Wrapper = ({ children }: { children: ReactNode }) => ( {children} @@ -16,7 +16,7 @@ describe('useGenerateFindManyRecordsForMultipleMetadataItemsQuery', () => { const mockObjectMetadataItems = getObjectMetadataItemsMock(); return useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ - objectMetadataItems: mockObjectMetadataItems.slice(0, 2), + targetObjectMetadataItems: mockObjectMetadataItems.slice(0, 2), }); }, { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useMapConnectionToRecords.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useMapConnectionToRecords.test.tsx deleted file mode 100644 index 9694cc6cee6b..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useMapConnectionToRecords.test.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { isNonEmptyArray } from '@sniptt/guards'; -import { renderHook } from '@testing-library/react'; - -import { Company } from '@/companies/types/Company'; -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { - companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, - emptyConnectionMock, - peopleWithTheirUniqueCompanies, -} from '@/object-record/hooks/__mocks__/useMapConnectionToRecords'; -import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; -import { Person } from '@/people/types/Person'; -import { getJestHookWrapper } from '~/testing/jest/getJestHookWrapper'; -import { isNonNullable } from '~/utils/isNonNullable'; - -const Wrapper = getJestHookWrapper({ - apolloMocks: [], - onInitializeRecoilSnapshot: (snapshot) => { - snapshot.set(objectMetadataItemsState, getObjectMetadataItemsMock()); - }, -}); - -describe('useMapConnectionToRecords', () => { - it('Empty edges - should return an empty array if no edge', async () => { - const { result } = renderHook( - () => { - const mapConnectionToRecords = useMapConnectionToRecords(); - - const records = mapConnectionToRecords({ - objectNameSingular: CoreObjectNameSingular.Company, - objectRecordConnection: emptyConnectionMock, - depth: 5, - }); - - return records; - }, - { - wrapper: Wrapper, - }, - ); - - expect(Array.isArray(result.current)).toBe(true); - }); - - it('No relation fields - should return an array of company records', async () => { - const { result } = renderHook( - () => { - const mapConnectionToRecords = useMapConnectionToRecords(); - - const records = mapConnectionToRecords({ - objectNameSingular: CoreObjectNameSingular.Company, - objectRecordConnection: - companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, - depth: 5, - }); - - return records; - }, - { - wrapper: Wrapper, - }, - ); - - expect(Array.isArray(result.current)).toBe(true); - }); - - it('n+1 relation fields - should return an array of company records with their people records', async () => { - const { result } = renderHook( - () => { - const mapConnectionToRecords = useMapConnectionToRecords(); - - const records = mapConnectionToRecords({ - objectNameSingular: CoreObjectNameSingular.Company, - objectRecordConnection: - companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, - depth: 5, - }); - - return records; - }, - { - wrapper: Wrapper, - }, - ); - - const secondCompanyMock = - companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock - .edges[1]; - - const secondCompanyPeopleMock = secondCompanyMock.node.people.edges.map( - (edge) => edge.node, - ); - - const companiesResult = result.current; - const secondCompanyResult = result.current[1]; - const secondCompanyPeopleResult = secondCompanyResult.people; - - expect(isNonEmptyArray(companiesResult)).toBe(true); - expect(secondCompanyResult.id).toBe(secondCompanyMock.node.id); - expect(isNonEmptyArray(secondCompanyPeopleResult)).toBe(true); - expect(secondCompanyPeopleResult[0].id).toEqual( - secondCompanyPeopleMock[0].id, - ); - }); - - it('n+2 relation fields - should return an array of company records with their people records with their favorites records', async () => { - const { result } = renderHook( - () => { - const mapConnectionToRecords = useMapConnectionToRecords(); - - const records = mapConnectionToRecords({ - objectNameSingular: CoreObjectNameSingular.Company, - objectRecordConnection: - companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock, - depth: 5, - }); - - return records; - }, - { - wrapper: Wrapper, - }, - ); - - const secondCompanyMock = - companiesConnectionWithPeopleConnectionWithFavoritesConnectionMock - .edges[1]; - - const secondCompanyPeopleMock = secondCompanyMock.node.people; - - const secondCompanyFirstPersonMock = secondCompanyPeopleMock.edges[0].node; - - const secondCompanyFirstPersonFavoritesMock = - secondCompanyFirstPersonMock.favorites; - - const companiesResult = result.current; - const secondCompanyResult = companiesResult[1]; - const secondCompanyPeopleResult = secondCompanyResult.people; - const secondCompanyFirstPersonResult = secondCompanyPeopleResult[0]; - const secondCompanyFirstPersonFavoritesResult = - secondCompanyFirstPersonResult.favorites; - - expect(isNonEmptyArray(companiesResult)).toBe(true); - expect(secondCompanyResult.id).toBe(secondCompanyMock.node.id); - expect(isNonEmptyArray(secondCompanyPeopleResult)).toBe(true); - expect(secondCompanyFirstPersonResult.id).toEqual( - secondCompanyFirstPersonMock.id, - ); - expect(isNonEmptyArray(secondCompanyFirstPersonFavoritesResult)).toBe(true); - expect(secondCompanyFirstPersonFavoritesResult[0].id).toEqual( - secondCompanyFirstPersonFavoritesMock.edges[0].node.id, - ); - }); - - it("n+1 relation field TO_ONE_OBJECT - should return an array of people records with their company, mapConnectionToRecords shouldn't try to parse TO_ONE_OBJECT", async () => { - const { result } = renderHook( - () => { - const mapConnectionToRecords = useMapConnectionToRecords(); - - const records = mapConnectionToRecords({ - objectNameSingular: CoreObjectNameSingular.Person, - objectRecordConnection: peopleWithTheirUniqueCompanies, - depth: 5, - }); - - return records as (Person & { company: Company })[]; - }, - { - wrapper: Wrapper, - }, - ); - - const firstPersonMock = peopleWithTheirUniqueCompanies.edges[0].node; - - const firstPersonsCompanyMock = firstPersonMock.company; - - const peopleResult = result.current; - - const firstPersonResult = result.current[0]; - const firstPersonsCompanyresult = firstPersonResult.company; - - expect(isNonEmptyArray(peopleResult)).toBe(true); - expect(firstPersonResult.id).toBe(firstPersonMock.id); - - expect(isNonNullable(firstPersonsCompanyresult)).toBe(true); - expect(firstPersonsCompanyresult.id).toEqual(firstPersonsCompanyMock.id); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useModifyRecordFromCache.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useModifyRecordFromCache.test.tsx deleted file mode 100644 index 9d1ac683d28d..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useModifyRecordFromCache.test.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ReactNode } from 'react'; -import { useApolloClient } from '@apollo/client'; -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { getObjectMetadataItemsMock } from '@/object-metadata/utils/getObjectMetadataItemsMock'; -import { useModifyRecordFromCache } from '@/object-record/cache/hooks/useModifyRecordFromCache'; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - {children} - -); - -const recordId = '91408718-a29f-4678-b573-c791e8664c2a'; - -describe('useModifyRecordFromCache', () => { - it('should work as expected', async () => { - const { result } = renderHook( - () => { - const apolloClient = useApolloClient(); - const mockObjectMetadataItems = getObjectMetadataItemsMock(); - - const personMetadataItem = mockObjectMetadataItems.find( - (item) => item.nameSingular === 'person', - )!; - - return { - modifyRecordFromCache: useModifyRecordFromCache({ - objectMetadataItem: personMetadataItem, - }), - cache: apolloClient.cache, - }; - }, - { - wrapper: Wrapper, - }, - ); - - const spy = jest.spyOn(result.current.cache, 'modify'); - - act(() => { - result.current.modifyRecordFromCache(recordId, {}); - }); - - expect(spy).toHaveBeenCalledWith({ - id: `Person:${recordId}`, - fields: {}, - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordBoard.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordBoard.test.tsx deleted file mode 100644 index a294d9d598aa..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordBoard.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { ReactNode } from 'react'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; - -import { useObjectRecordBoardDeprecated } from '@/object-record/hooks/useObjectRecordBoardDeprecated'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; - -const recordBoardId = '783932a0-28c7-4607-b2ce-6543fa2be892'; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - - {children} - - - -); - -describe('useObjectRecordBoardDeprecated', () => { - it('should skip fetch if currentWorkspace is undefined', async () => { - const { result } = renderHook(() => useObjectRecordBoardDeprecated(), { - wrapper: Wrapper, - }); - - expect(result.current.loading).toBe(false); - expect(Array.isArray(result.current.opportunities)).toBe(true); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts index 5e133b86882f..1a62d1e46e49 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecords.ts @@ -2,56 +2,84 @@ import { useApolloClient } from '@apollo/client'; import { v4 } from 'uuid'; import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; -import { getCreateManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateCreateManyRecordMutation'; +import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache'; +import { useCreateManyRecordsMutation } from '@/object-record/hooks/useCreateManyRecordsMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; +import { isDefined } from '~/utils/isDefined'; -type CreateManyRecordsOptions = { - skipOptimisticEffect?: boolean; +type useCreateManyRecordsProps = { + objectNameSingular: string; + queryFields?: Record; + depth?: number; + skipPostOptmisticEffect?: boolean; }; export const useCreateManyRecords = < CreatedObjectRecord extends ObjectRecord = ObjectRecord, >({ objectNameSingular, -}: ObjectMetadataItemIdentifier) => { + queryFields, + depth = 1, + skipPostOptmisticEffect = false, +}: useCreateManyRecordsProps) => { const apolloClient = useApolloClient(); - const { objectMetadataItem, createManyRecordsMutation } = - useObjectMetadataItem({ - objectNameSingular, - }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); - const { generateObjectRecordOptimisticResponse } = - useGenerateObjectRecordOptimisticResponse({ - objectMetadataItem, - }); + const { createManyRecordsMutation } = useCreateManyRecordsMutation({ + objectNameSingular, + queryFields, + depth, + }); + + const createOneRecordInCache = useCreateOneRecordInCache({ + objectMetadataItem, + }); const { objectMetadataItems } = useObjectMetadataItems(); const createManyRecords = async ( - data: Partial[], - options?: CreateManyRecordsOptions, + recordsToCreate: Partial[], ) => { - const sanitizedCreateManyRecordsInput = data.map((input) => { - const idForCreation = input.id ?? v4(); + const sanitizedCreateManyRecordsInput = recordsToCreate.map( + (recordToCreate) => { + const idForCreation = recordToCreate?.id ?? v4(); + + return { + ...sanitizeRecordInput({ + objectMetadataItem, + recordInput: recordToCreate, + }), + id: idForCreation, + }; + }, + ); - const sanitizedRecordInput = sanitizeRecordInput({ - objectMetadataItem, - recordInput: { ...input, id: idForCreation }, - }); + const recordsCreatedInCache = []; - return sanitizedRecordInput; - }); + for (const recordToCreate of sanitizedCreateManyRecordsInput) { + const recordCreatedInCache = createOneRecordInCache(recordToCreate); - const optimisticallyCreatedRecords = sanitizedCreateManyRecordsInput.map( - (record) => - generateObjectRecordOptimisticResponse(record), - ); + if (isDefined(recordCreatedInCache)) { + recordsCreatedInCache.push(recordCreatedInCache); + } + } + + if (recordsCreatedInCache.length > 0) { + triggerCreateRecordsOptimisticEffect({ + cache: apolloClient.cache, + objectMetadataItem, + recordsToCreate: recordsCreatedInCache, + objectMetadataItems, + }); + } const mutationResponseField = getCreateManyRecordsMutationResponseField( objectMetadataItem.namePlural, @@ -62,25 +90,18 @@ export const useCreateManyRecords = < variables: { data: sanitizedCreateManyRecordsInput, }, - optimisticResponse: options?.skipOptimisticEffect - ? undefined - : { - [mutationResponseField]: optimisticallyCreatedRecords, - }, - update: options?.skipOptimisticEffect - ? undefined - : (cache, { data }) => { - const records = data?.[mutationResponseField]; - - if (!records?.length) return; - - triggerCreateRecordsOptimisticEffect({ - cache, - objectMetadataItem, - recordsToCreate: records, - objectMetadataItems, - }); - }, + update: (cache, { data }) => { + const records = data?.[mutationResponseField]; + + if (!records?.length || skipPostOptmisticEffect) return; + + triggerCreateRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToCreate: records, + objectMetadataItems, + }); + }, }); return createdObjects.data?.[mutationResponseField] ?? []; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts deleted file mode 100644 index 426e4176f91c..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsInCache.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { v4 } from 'uuid'; - -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache'; -import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; - -export const useCreateManyRecordsInCache = ({ - objectNameSingular, -}: ObjectMetadataItemIdentifier) => { - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - - const { generateObjectRecordOptimisticResponse } = - useGenerateObjectRecordOptimisticResponse({ - objectMetadataItem, - }); - - const addRecordInCache = useAddRecordInCache({ - objectMetadataItem, - }); - - const createManyRecordsInCache = (data: Partial[]) => { - const recordsWithId = data.map((record) => ({ - ...record, - id: (record.id as string) ?? v4(), - })); - - const createdRecordsInCache = [] as T[]; - - for (const record of recordsWithId) { - const generatedCachedObjectRecord = - generateObjectRecordOptimisticResponse(record); - - if (generatedCachedObjectRecord) { - addRecordInCache(generatedCachedObjectRecord); - - createdRecordsInCache.push(generatedCachedObjectRecord); - } - } - - return createdRecordsInCache; - }; - - return { createManyRecordsInCache }; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsMutation.ts new file mode 100644 index 000000000000..5c43384ad851 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateManyRecordsMutation.ts @@ -0,0 +1,50 @@ +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { getCreateManyRecordsMutationResponseField } from '@/object-record/utils/getCreateManyRecordsMutationResponseField'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useCreateManyRecordsMutation = ({ + objectNameSingular, + queryFields, + depth, +}: { + objectNameSingular: string; + queryFields?: Record; + depth?: number; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { createManyRecordsMutation: EMPTY_MUTATION }; + } + + const mutationResponseField = getCreateManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + + const createManyRecordsMutation = gql` + mutation Create${capitalize( + objectMetadataItem.namePlural, + )}($data: [${capitalize(objectMetadataItem.nameSingular)}CreateInput!]!) { + ${mutationResponseField}(data: $data) ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + queryFields, + depth, + })} + }`; + + return { + createManyRecordsMutation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts index 938f848a896d..ba227173d7cf 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecord.ts @@ -2,55 +2,71 @@ import { useApolloClient } from '@apollo/client'; import { v4 } from 'uuid'; import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; +import { CachedObjectRecord } from '@/apollo/types/CachedObjectRecord'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; -import { getCreateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateCreateOneRecordMutation'; +import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache'; +import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; +import { isDefined } from '~/utils/isDefined'; type useCreateOneRecordProps = { objectNameSingular: string; -}; - -type CreateOneRecordOptions = { - skipOptimisticEffect?: boolean; + queryFields?: Record; + depth?: number; + skipPostOptmisticEffect?: boolean; }; export const useCreateOneRecord = < CreatedObjectRecord extends ObjectRecord = ObjectRecord, >({ objectNameSingular, + queryFields, + skipPostOptmisticEffect = false, }: useCreateOneRecordProps) => { const apolloClient = useApolloClient(); - const { objectMetadataItem, createOneRecordMutation } = useObjectMetadataItem( - { objectNameSingular }, - ); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); - const { generateObjectRecordOptimisticResponse } = - useGenerateObjectRecordOptimisticResponse({ - objectMetadataItem, - }); + const { createOneRecordMutation } = useCreateOneRecordMutation({ + objectNameSingular, + queryFields, + }); + + const createOneRecordInCache = useCreateOneRecordInCache({ + objectMetadataItem, + }); const { objectMetadataItems } = useObjectMetadataItems(); - const createOneRecord = async ( - input: Partial, - options?: CreateOneRecordOptions, - ) => { + const createOneRecord = async (input: Partial) => { const idForCreation = input.id ?? v4(); - const sanitizedCreateOneRecordInput = sanitizeRecordInput({ - objectMetadataItem, - recordInput: { ...input, id: idForCreation }, + const sanitizedInput = { + ...sanitizeRecordInput({ + objectMetadataItem, + recordInput: input, + }), + id: idForCreation, + }; + + const recordCreatedInCache = createOneRecordInCache({ + ...input, + id: idForCreation, }); - const optimisticallyCreatedRecord = - generateObjectRecordOptimisticResponse({ - ...input, - ...sanitizedCreateOneRecordInput, + if (isDefined(recordCreatedInCache)) { + triggerCreateRecordsOptimisticEffect({ + cache: apolloClient.cache, + objectMetadataItem, + recordsToCreate: [recordCreatedInCache], + objectMetadataItems, }); + } const mutationResponseField = getCreateOneRecordMutationResponseField(objectNameSingular); @@ -58,27 +74,20 @@ export const useCreateOneRecord = < const createdObject = await apolloClient.mutate({ mutation: createOneRecordMutation, variables: { - input: sanitizedCreateOneRecordInput, + input: sanitizedInput, + }, + update: (cache, { data }) => { + const record = data?.[mutationResponseField]; + + if (!record || skipPostOptmisticEffect) return; + + triggerCreateRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToCreate: [record], + objectMetadataItems, + }); }, - optimisticResponse: options?.skipOptimisticEffect - ? undefined - : { - [mutationResponseField]: optimisticallyCreatedRecord, - }, - update: options?.skipOptimisticEffect - ? undefined - : (cache, { data }) => { - const record = data?.[mutationResponseField]; - - if (!record) return; - - triggerCreateRecordsOptimisticEffect({ - cache, - objectMetadataItem, - recordsToCreate: [record], - objectMetadataItems, - }); - }, }); return createdObject.data?.[mutationResponseField] ?? null; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts deleted file mode 100644 index ebd22838c45c..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordInCache.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useAddRecordInCache } from '@/object-record/cache/hooks/useAddRecordInCache'; -import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; - -type useCreateOneRecordInCacheProps = { - objectNameSingular: string; -}; - -export const useCreateOneRecordInCache = ({ - objectNameSingular, -}: useCreateOneRecordInCacheProps) => { - const { objectMetadataItem } = useObjectMetadataItem({ - objectNameSingular, - }); - - const { generateObjectRecordOptimisticResponse } = - useGenerateObjectRecordOptimisticResponse({ - objectMetadataItem, - }); - - const addRecordInCache = useAddRecordInCache({ - objectMetadataItem, - }); - - const createOneRecordInCache = (input: ObjectRecord) => { - const generatedCachedObjectRecord = - generateObjectRecordOptimisticResponse(input); - - addRecordInCache(generatedCachedObjectRecord); - - return generatedCachedObjectRecord as T; - }; - - return { - createOneRecordInCache, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordMutation.ts new file mode 100644 index 000000000000..679133685553 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useCreateOneRecordMutation.ts @@ -0,0 +1,51 @@ +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { getCreateOneRecordMutationResponseField } from '@/object-record/utils/getCreateOneRecordMutationResponseField'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useCreateOneRecordMutation = ({ + objectNameSingular, + queryFields, + depth, +}: { + objectNameSingular: string; + queryFields?: Record; + depth?: number; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { createOneRecordMutation: EMPTY_MUTATION }; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const mutationResponseField = getCreateOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + + const createOneRecordMutation = gql` + mutation CreateOne${capitalizedObjectName}($input: ${capitalizedObjectName}CreateInput!) { + ${mutationResponseField}(data: $input) ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + queryFields, + depth, + })} + } + `; + + return { + createOneRecordMutation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts index 7b2f392cf261..f189d516da1b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecords.ts @@ -3,8 +3,10 @@ import { useApolloClient } from '@apollo/client'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { getDeleteManyRecordsMutationResponseField } from '@/object-record/hooks/useGenerateDeleteManyRecordMutation'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { useDeleteManyRecordsMutation } from '@/object-record/hooks/useDeleteManyRecordsMutation'; +import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -21,8 +23,17 @@ export const useDeleteManyRecords = ({ }: useDeleteOneRecordProps) => { const apolloClient = useApolloClient(); - const { objectMetadataItem, deleteManyRecordsMutation, getRecordFromCache } = - useObjectMetadataItem({ objectNameSingular }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular, + }); + + const { deleteManyRecordsMutation } = useDeleteManyRecordsMutation({ + objectNameSingular, + }); const { objectMetadataItems } = useObjectMetadataItems(); @@ -56,7 +67,7 @@ export const useDeleteManyRecords = ({ const cachedRecords = records .map((record) => getRecordFromCache(record.id, cache)) - .filter(isNonNullable); + .filter(isDefined); triggerDeleteRecordsOptimisticEffect({ cache, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecordsMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecordsMutation.ts new file mode 100644 index 000000000000..2e13249be8aa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteManyRecordsMutation.ts @@ -0,0 +1,41 @@ +import gql from 'graphql-tag'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { getDeleteManyRecordsMutationResponseField } from '@/object-record/utils/getDeleteManyRecordsMutationResponseField'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useDeleteManyRecordsMutation = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { deleteManyRecordsMutation: EMPTY_MUTATION }; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.namePlural); + + const mutationResponseField = getDeleteManyRecordsMutationResponseField( + objectMetadataItem.namePlural, + ); + + const deleteManyRecordsMutation = gql` + mutation DeleteMany${capitalizedObjectName}($filter: ${capitalize( + objectMetadataItem.nameSingular, + )}FilterInput!) { + ${mutationResponseField}(filter: $filter) { + id + } + } + `; + + return { + deleteManyRecordsMutation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts index e43a30e85ade..bf7dcd778fd0 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecord.ts @@ -4,7 +4,9 @@ import { useApolloClient } from '@apollo/client'; import { triggerDeleteRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDeleteRecordsOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/generateDeleteOneRecordMutation'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { useDeleteOneRecordMutation } from '@/object-record/hooks/useDeleteOneRecordMutation'; +import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField'; import { capitalize } from '~/utils/string/capitalize'; type useDeleteOneRecordProps = { @@ -17,8 +19,17 @@ export const useDeleteOneRecord = ({ }: useDeleteOneRecordProps) => { const apolloClient = useApolloClient(); - const { objectMetadataItem, deleteOneRecordMutation, getRecordFromCache } = - useObjectMetadataItem({ objectNameSingular }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular, + }); + + const { deleteOneRecordMutation } = useDeleteOneRecordMutation({ + objectNameSingular, + }); const { objectMetadataItems } = useObjectMetadataItems(); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecordMutation.ts new file mode 100644 index 000000000000..46dcb9422568 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useDeleteOneRecordMutation.ts @@ -0,0 +1,39 @@ +import gql from 'graphql-tag'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { getDeleteOneRecordMutationResponseField } from '@/object-record/utils/getDeleteOneRecordMutationResponseField'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useDeleteOneRecordMutation = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { deleteOneRecordMutation: EMPTY_MUTATION }; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const mutationResponseField = getDeleteOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + + const deleteOneRecordMutation = gql` + mutation DeleteOne${capitalizedObjectName}($idToDelete: UUID!) { + ${mutationResponseField}(id: $idToDelete) { + id + } + } + `; + + return { + deleteOneRecordMutation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecord.ts index fc9884473e70..10b67d64182a 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecord.ts @@ -3,6 +3,8 @@ import { useApolloClient } from '@apollo/client'; import { getOperationName } from '@apollo/client/utilities'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useExecuteQuickActionOnOneRecordMutation } from '@/object-record/hooks/useExecuteQuickActionOnOneRecordMutation'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; import { capitalize } from '~/utils/string/capitalize'; type useExecuteQuickActionOnOneRecordProps = { @@ -12,11 +14,16 @@ type useExecuteQuickActionOnOneRecordProps = { export const useExecuteQuickActionOnOneRecord = ({ objectNameSingular, }: useExecuteQuickActionOnOneRecordProps) => { - const { - objectMetadataItem, - executeQuickActionOnOneRecordMutation, - findManyRecordsQuery, - } = useObjectMetadataItem({ + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { executeQuickActionOnOneRecordMutation } = + useExecuteQuickActionOnOneRecordMutation({ + objectNameSingular, + }); + + const { findManyRecordsQuery } = useFindManyRecordsQuery({ objectNameSingular, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecordMutation.ts new file mode 100644 index 000000000000..38ed43f84c42 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useExecuteQuickActionOnOneRecordMutation.ts @@ -0,0 +1,53 @@ +import { gql } from '@apollo/client'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const getExecuteQuickActionOnOneRecordMutationGraphQLField = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + return `executeQuickActionOn${capitalize(objectNameSingular)}`; +}; + +export const useExecuteQuickActionOnOneRecordMutation = ({ + objectNameSingular, +}: { + objectNameSingular: string; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { executeQuickActionOnOneRecordMutation: EMPTY_MUTATION }; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const graphQLFieldForExecuteQuickActionOnOneRecordMutation = + getExecuteQuickActionOnOneRecordMutationGraphQLField({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const executeQuickActionOnOneRecordMutation = gql` + mutation ExecuteQuickActionOnOne${capitalizedObjectName}($idToExecuteQuickActionOn: UUID!) { + ${graphQLFieldForExecuteQuickActionOnOneRecordMutation}(id: $idToExecuteQuickActionOn) ${mapObjectMetadataToGraphQLQuery( + { + objectMetadataItems, + objectMetadataItem, + }, + )} + } + `; + + return { executeQuickActionOnOneRecordMutation }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx b/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx index 3429c83406e5..a2ea0646b173 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/useFieldContext.tsx @@ -2,6 +2,7 @@ import { ReactNode } from 'react'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; +import { getBasePathToShowPage } from '@/object-metadata/utils/getBasePathToShowPage'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { FieldContext, @@ -27,7 +28,11 @@ export const useFieldContext = ({ objectRecordId: string; customUseUpdateOneObjectHook?: RecordUpdateHook; }) => { - const { basePathToShowPage, objectMetadataItem } = useObjectMetadataItem({ + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const basePathToShowPage = getBasePathToShowPage({ objectNameSingular, }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts index 3a88f95ce871..f1c2efbec9d7 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicateRecords.ts @@ -3,10 +3,11 @@ import { useQuery } from '@apollo/client'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { getFindDuplicateRecordsQueryResponseField } from '@/object-record/hooks/useGenerateFindDuplicateRecordsQuery'; -import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { useFindDuplicateRecordsQuery } from '@/object-record/hooks/useFindDuplicatesRecordsQuery'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; +import { getFindDuplicateRecordsQueryResponseField } from '@/object-record/utils/getFindDuplicateRecordsQueryResponseField'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { logError } from '~/utils/logError'; @@ -25,8 +26,14 @@ export const useFindDuplicateRecords = ({ }) => { const findDuplicateQueryStateIdentifier = objectNameSingular; - const { objectMetadataItem, findDuplicateRecordsQuery } = - useObjectMetadataItem({ objectNameSingular }, depth); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { findDuplicateRecordsQuery } = useFindDuplicateRecordsQuery({ + objectNameSingular, + depth, + }); const { enqueueSnackBar } = useSnackBar(); @@ -60,16 +67,14 @@ export const useFindDuplicateRecords = ({ const objectRecordConnection = data?.[queryResponseField]; - const mapConnectionToRecords = useMapConnectionToRecords(); - const records = useMemo( () => - mapConnectionToRecords({ - objectRecordConnection, - objectNameSingular, - depth: 5, - }) as T[], - [mapConnectionToRecords, objectRecordConnection, objectNameSingular], + objectRecordConnection + ? (getRecordsFromRecordConnection({ + recordConnection: objectRecordConnection, + }) as T[]) + : [], + [objectRecordConnection], ); return { diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicatesRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicatesRecordsQuery.ts new file mode 100644 index 000000000000..ae0a89ba7bb2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindDuplicatesRecordsQuery.ts @@ -0,0 +1,51 @@ +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { getFindDuplicateRecordsQueryResponseField } from '@/object-record/utils/getFindDuplicateRecordsQueryResponseField'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useFindDuplicateRecordsQuery = ({ + objectNameSingular, + depth, +}: { + objectNameSingular: string; + depth?: number; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const findDuplicateRecordsQuery = gql` + query FindDuplicate${capitalize( + objectMetadataItem.nameSingular, + )}($id: UUID) { + ${getFindDuplicateRecordsQueryResponseField( + objectMetadataItem.nameSingular, + )}(id: $id) { + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + depth, + })} + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } + } + `; + + return { + findDuplicateRecordsQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts index d3651828b8c5..c32e0c17f9cd 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecords.ts @@ -7,13 +7,15 @@ import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; -import { useMapConnectionToRecords } from '@/object-record/hooks/useMapConnectionToRecords'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { useFindManyRecordsQuery } from '@/object-record/hooks/useFindManyRecordsQuery'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; import { filterUniqueRecordEdgesByCursor } from '@/object-record/utils/filterUniqueRecordEdgesByCursor'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { isDefined } from '~/utils/isDefined'; import { logError } from '~/utils/logError'; import { capitalize } from '~/utils/string/capitalize'; @@ -21,7 +23,6 @@ import { cursorFamilyState } from '../states/cursorFamilyState'; import { hasNextPageFamilyState } from '../states/hasNextPageFamilyState'; import { isFetchingMoreRecordsFamilyState } from '../states/isFetchingMoreRecordsFamilyState'; import { ObjectRecordQueryResult } from '../types/ObjectRecordQueryResult'; -import { mapPaginatedRecordsToRecords } from '../utils/mapPaginatedRecordsToRecords'; export const useFindManyRecords = ({ objectNameSingular, @@ -30,17 +31,19 @@ export const useFindManyRecords = ({ limit, onCompleted, skip, - useRecordsWithoutConnection = false, - depth, + queryFields, }: ObjectMetadataItemIdentifier & ObjectRecordQueryVariables & { onCompleted?: ( - data: ObjectRecordConnection, - pageInfo: ObjectRecordConnection['pageInfo'], + records: T[], + options?: { + pageInfo?: ObjectRecordConnection['pageInfo']; + totalCount?: number; + }, ) => void; skip?: boolean; - useRecordsWithoutConnection?: boolean; depth?: number; + queryFields?: Record; }) => { const findManyQueryStateIdentifier = objectNameSingular + @@ -60,12 +63,14 @@ export const useFindManyRecords = ({ isFetchingMoreRecordsFamilyState(findManyQueryStateIdentifier), ); - const { objectMetadataItem, findManyRecordsQuery } = useObjectMetadataItem( - { - objectNameSingular, - }, - depth, - ); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { findManyRecordsQuery } = useFindManyRecordsQuery({ + objectNameSingular, + queryFields, + }); const { enqueueSnackBar } = useSnackBar(); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); @@ -80,11 +85,22 @@ export const useFindManyRecords = ({ orderBy, }, onCompleted: (data) => { + if (!isDefined(data)) { + onCompleted?.([]); + } + const pageInfo = data?.[objectMetadataItem.namePlural]?.pageInfo; - onCompleted?.(data[objectMetadataItem.namePlural], pageInfo); + const records = getRecordsFromRecordConnection({ + recordConnection: data?.[objectMetadataItem.namePlural], + }) as T[]; + + onCompleted?.(records, { + pageInfo, + totalCount: data?.[objectMetadataItem.namePlural]?.totalCount, + }); - if (data?.[objectMetadataItem.namePlural]) { + if (isDefined(data?.[objectMetadataItem.namePlural])) { setLastCursor(pageInfo.endCursor ?? ''); setHasNextPage(pageInfo.hasNextPage ?? false); } @@ -131,24 +147,24 @@ export const useFindManyRecords = ({ const pageInfo = fetchMoreResult?.[objectMetadataItem.namePlural]?.pageInfo; - if (data?.[objectMetadataItem.namePlural]) { + + if (isDefined(data?.[objectMetadataItem.namePlural])) { setLastCursor(pageInfo.endCursor ?? ''); setHasNextPage(pageInfo.hasNextPage ?? false); } - onCompleted?.( - { - __typename: `${capitalize( - objectMetadataItem.nameSingular, - )}Connection`, + const records = getRecordsFromRecordConnection({ + recordConnection: { edges: newEdges, - pageInfo: - fetchMoreResult?.[objectMetadataItem.namePlural].pageInfo, - totalCount: - fetchMoreResult?.[objectMetadataItem.namePlural].totalCount, + pageInfo, }, + }) as T[]; + + onCompleted?.(records, { pageInfo, - ); + totalCount: + fetchMoreResult?.[objectMetadataItem.namePlural]?.totalCount, + }); return Object.assign({}, prev, { [objectMetadataItem.namePlural]: { @@ -195,40 +211,23 @@ export const useFindManyRecords = ({ enqueueSnackBar, ]); - // TODO: remove this and use only mapConnectionToRecords when we've finished the refactor + const totalCount = data?.[objectMetadataItem.namePlural].totalCount ?? 0; + const records = useMemo( () => - mapPaginatedRecordsToRecords({ - pagedRecords: data, - objectNamePlural: objectMetadataItem.namePlural, - }) as T[], - [data, objectMetadataItem], - ); + data?.[objectMetadataItem.namePlural] + ? getRecordsFromRecordConnection({ + recordConnection: data?.[objectMetadataItem.namePlural], + }) + : ([] as T[]), - const mapConnectionToRecords = useMapConnectionToRecords(); - - const recordsWithoutConnection = useMemo( - () => - useRecordsWithoutConnection - ? (mapConnectionToRecords({ - objectRecordConnection: data?.[objectMetadataItem.namePlural], - objectNameSingular, - depth: 5, - }) as T[]) - : [], - [ - data, - objectNameSingular, - objectMetadataItem.namePlural, - mapConnectionToRecords, - useRecordsWithoutConnection, - ], + [data, objectMetadataItem.namePlural], ); return { objectMetadataItem, - records: useRecordsWithoutConnection ? recordsWithoutConnection : records, - totalCount: data?.[objectMetadataItem.namePlural].totalCount || 0, + records, + totalCount, loading, error, fetchMoreRecords, diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts new file mode 100644 index 000000000000..e41481c41c3d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindManyRecordsQuery.ts @@ -0,0 +1,36 @@ +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { QueryFields } from '@/object-record/query-keys/types/QueryFields'; +import { generateFindManyRecordsQuery } from '@/object-record/utils/generateFindManyRecordsQuery'; + +export const useFindManyRecordsQuery = ({ + objectNameSingular, + queryFields, + depth, + computeReferences, +}: { + objectNameSingular: string; + queryFields?: QueryFields; + depth?: number; + computeReferences?: boolean; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const findManyRecordsQuery = generateFindManyRecordsQuery({ + objectMetadataItem, + objectMetadataItems, + queryFields, + depth, + computeReferences, + }); + + return { + findManyRecordsQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts index ade2c951e30d..acb2f92c45ed 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecord.ts @@ -1,26 +1,33 @@ +import { useMemo } from 'react'; import { useQuery } from '@apollo/client'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; +import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { isDefined } from '~/utils/isDefined'; -// TODO: fix connection in relation => automatically change to an array export const useFindOneRecord = ({ objectNameSingular, objectRecordId = '', onCompleted, - depth, skip, + depth, }: ObjectMetadataItemIdentifier & { objectRecordId: string | undefined; onCompleted?: (data: T) => void; skip?: boolean; depth?: number; }) => { - const { objectMetadataItem, findOneRecordQuery } = useObjectMetadataItem( - { objectNameSingular }, + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { findOneRecordQuery } = useFindOneRecordQuery({ + objectNameSingular, depth, - ); + }); const { data, loading, error } = useQuery< { [nameSingular: string]: T }, @@ -28,11 +35,30 @@ export const useFindOneRecord = ({ >(findOneRecordQuery, { skip: !objectMetadataItem || !objectRecordId || skip, variables: { objectRecordId }, - onCompleted: (data) => onCompleted?.(data[objectNameSingular]), + onCompleted: (data) => { + const recordWithoutConnection = getRecordFromRecordNode({ + recordNode: { ...data[objectNameSingular] }, + }); + + if (isDefined(recordWithoutConnection)) { + onCompleted?.(recordWithoutConnection); + } + }, }); + // TODO: Remove connection from record + const recordWithoutConnection = useMemo( + () => + data?.[objectNameSingular] + ? getRecordFromRecordNode({ + recordNode: data?.[objectNameSingular], + }) + : undefined, + [data, objectNameSingular], + ); + return { - record: data?.[objectNameSingular] || undefined, + record: recordWithoutConnection, loading, error, }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecordQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecordQuery.ts new file mode 100644 index 000000000000..87f141f5cbc1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useFindOneRecordQuery.ts @@ -0,0 +1,41 @@ +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useFindOneRecordQuery = ({ + objectNameSingular, + depth, +}: { + objectNameSingular: string; + depth?: number; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + const findOneRecordQuery = gql` + query FindOne${capitalize( + objectMetadataItem.nameSingular, + )}($objectRecordId: UUID!) { + ${objectMetadataItem.nameSingular}(filter: { + id: { + eq: $objectRecordId + } + })${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + depth, + })} + } + `; + + return { + findOneRecordQuery, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts deleted file mode 100644 index 1f9a92b4970e..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateManyRecordMutation.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getCreateManyRecordsMutationResponseField = ( - objectNamePlural: string, -) => `create${capitalize(objectNamePlural)}`; - -export const useGenerateCreateManyRecordMutation = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } - - const mutationResponseField = getCreateManyRecordsMutationResponseField( - objectMetadataItem.namePlural, - ); - - return gql` - mutation Create${capitalize( - objectMetadataItem.namePlural, - )}($data: [${capitalize(objectMetadataItem.nameSingular)}CreateInput!]!) { - ${mutationResponseField}(data: $data) { - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - }), - ) - .join('\n')} - } - }`; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts deleted file mode 100644 index cd607c83faac..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateCreateOneRecordMutation.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getCreateOneRecordMutationResponseField = ( - objectNameSingular: string, -) => `create${capitalize(objectNameSingular)}`; - -export const useGenerateCreateOneRecordMutation = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } - - const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); - - const mutationResponseField = getCreateOneRecordMutationResponseField( - objectMetadataItem.nameSingular, - ); - - return gql` - mutation CreateOne${capitalizedObjectName}($input: ${capitalizedObjectName}CreateInput!) { - ${mutationResponseField}(data: $input) { - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - }), - ) - .join('\n')} - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts deleted file mode 100644 index af80fd645343..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateDeleteManyRecordMutation.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { gql } from '@apollo/client'; - -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getDeleteManyRecordsMutationResponseField = ( - objectNamePlural: string, -) => `delete${capitalize(objectNamePlural)}`; - -export const useGenerateDeleteManyRecordMutation = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } - - const capitalizedObjectName = capitalize(objectMetadataItem.namePlural); - - const mutationResponseField = getDeleteManyRecordsMutationResponseField( - objectMetadataItem.namePlural, - ); - - return gql` - mutation DeleteMany${capitalizedObjectName}($filter: ${capitalize( - objectMetadataItem.nameSingular, - )}FilterInput!) { - ${mutationResponseField}(filter: $filter) { - id - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation.ts deleted file mode 100644 index 743eee38f65d..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateExecuteQuickActionOnOneRecordMutation.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getExecuteQuickActionOnOneRecordMutationGraphQLField = ({ - objectNameSingular, -}: { - objectNameSingular: string; -}) => { - return `executeQuickActionOn${capitalize(objectNameSingular)}`; -}; - -export const useGenerateExecuteQuickActionOnOneRecordMutation = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } - - const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); - - const graphQLFieldForExecuteQuickActionOnOneRecordMutation = - getExecuteQuickActionOnOneRecordMutationGraphQLField({ - objectNameSingular: objectMetadataItem.nameSingular, - }); - - return gql` - mutation ExecuteQuickActionOnOne${capitalizedObjectName}($idToExecuteQuickActionOn: ID!) { - ${graphQLFieldForExecuteQuickActionOnOneRecordMutation}(id: $idToExecuteQuickActionOn) { - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - }), - ) - .join('\n')} - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts deleted file mode 100644 index 0f5911b71c14..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindDuplicateRecordsQuery.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getFindDuplicateRecordsQueryResponseField = ( - objectNameSingular: string, -) => `${objectNameSingular}Duplicates`; - -export const useGenerateFindDuplicateRecordsQuery = () => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - return ({ - objectMetadataItem, - depth, - }: { - objectMetadataItem: ObjectMetadataItem; - depth?: number; - }) => gql` - query FindDuplicate${capitalize(objectMetadataItem.nameSingular)}($id: ID) { - ${getFindDuplicateRecordsQueryResponseField( - objectMetadataItem.nameSingular, - )}(id: $id) { - edges { - node { - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: depth, - }), - ) - .join('\n')} - } - cursor - } - pageInfo { - hasNextPage - startCursor - endCursor - } - totalCount - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts deleted file mode 100644 index 3ae1bf241e27..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useGenerateFindManyRecordsForMultipleMetadataItemsQuery = ({ - objectMetadataItems, - depth, -}: { - objectMetadataItems: ObjectMetadataItem[]; - depth?: number; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - const capitalizedObjectNameSingulars = objectMetadataItems.map( - ({ nameSingular }) => capitalize(nameSingular), - ); - - if (!isNonEmptyArray(capitalizedObjectNameSingulars)) { - return null; - } - - const filterPerMetadataItemArray = capitalizedObjectNameSingulars - .map( - (capitalizedObjectNameSingular) => - `$filter${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}FilterInput`, - ) - .join(', '); - - const orderByPerMetadataItemArray = capitalizedObjectNameSingulars - .map( - (capitalizedObjectNameSingular) => - `$orderBy${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}OrderByInput`, - ) - .join(', '); - - const lastCursorPerMetadataItemArray = capitalizedObjectNameSingulars - .map( - (capitalizedObjectNameSingular) => - `$lastCursor${capitalizedObjectNameSingular}: String`, - ) - .join(', '); - - const limitPerMetadataItemArray = capitalizedObjectNameSingulars - .map( - (capitalizedObjectNameSingular) => - `$limit${capitalizedObjectNameSingular}: Float = 5`, - ) - .join(', '); - - return gql` - query FindManyRecordsMultipleMetadataItems( - ${filterPerMetadataItemArray}, - ${orderByPerMetadataItemArray}, - ${lastCursorPerMetadataItemArray}, - ${limitPerMetadataItemArray} - ) { - ${objectMetadataItems - .map( - ({ namePlural, nameSingular, fields }) => - `${namePlural}(filter: $filter${capitalize( - nameSingular, - )}, orderBy: $orderBy${capitalize( - nameSingular, - )}, first: $limit${capitalize( - nameSingular, - )}, after: $lastCursor${capitalize(nameSingular)}){ - edges { - node { - id - ${fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: depth, - }), - ) - .join('\n')} - } - cursor - } - pageInfo { - hasNextPage - startCursor - endCursor - } - }`, - ) - .join('\n')} - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts deleted file mode 100644 index 46cd279fc29a..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindManyRecordsQuery.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const useGenerateFindManyRecordsQuery = () => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - return ({ - objectMetadataItem, - depth, - }: { - objectMetadataItem: ObjectMetadataItem; - depth?: number; - }) => gql` - query FindMany${capitalize( - objectMetadataItem.namePlural, - )}($filter: ${capitalize( - objectMetadataItem.nameSingular, - )}FilterInput, $orderBy: ${capitalize( - objectMetadataItem.nameSingular, - )}OrderByInput, $lastCursor: String, $limit: Float) { - ${ - objectMetadataItem.namePlural - }(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){ - edges { - node { - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: depth, - }), - ) - .join('\n')} - } - cursor - } - pageInfo { - hasNextPage - startCursor - endCursor - } - totalCount - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts deleted file mode 100644 index aee59872abb1..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateFindOneRecordQuery.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; - -export const useGenerateFindOneRecordQuery = () => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - return ({ - objectMetadataItem, - depth, - }: { - objectMetadataItem: Pick; - depth?: number; - }) => { - return gql` - query FindOne${objectMetadataItem.nameSingular}($objectRecordId: UUID!) { - ${objectMetadataItem.nameSingular}(filter: { - id: { - eq: $objectRecordId - } - }){ - id - ${objectMetadataItem.fields - .map((field) => - mapFieldMetadataToGraphQLQuery({ - field, - maxDepthForRelations: depth, - }), - ) - .join('\n')} - } - } - `; - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts deleted file mode 100644 index 8a00f1beef0c..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useGenerateUpdateOneRecordMutation.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { gql } from '@apollo/client'; - -import { useMapFieldMetadataToGraphQLQuery } from '@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery'; -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getUpdateOneRecordMutationResponseField = ( - objectNameSingular: string, -) => `update${capitalize(objectNameSingular)}`; - -export const useGenerateUpdateOneRecordMutation = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - const mapFieldMetadataToGraphQLQuery = useMapFieldMetadataToGraphQLQuery(); - - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } - - const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); - - const mutationResponseField = getUpdateOneRecordMutationResponseField( - objectMetadataItem.nameSingular, - ); - - return gql` - mutation UpdateOne${capitalizedObjectName}($idToUpdate: ID!, $input: ${capitalizedObjectName}UpdateInput!) { - ${mutationResponseField}(id: $idToUpdate, data: $input) { - id - ${objectMetadataItem.fields - .map((field) => mapFieldMetadataToGraphQLQuery({ field })) - .join('\n')} - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts new file mode 100644 index 000000000000..a97a4cca87d9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useLazyFindOneRecord.ts @@ -0,0 +1,45 @@ +import { useLazyQuery } from '@apollo/client'; + +import { ObjectMetadataItemIdentifier } from '@/object-metadata/types/ObjectMetadataItemIdentifier'; +import { getRecordFromRecordNode } from '@/object-record/cache/utils/getRecordFromRecordNode'; +import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; + +type UseLazyFindOneRecordParams = ObjectMetadataItemIdentifier & { + depth?: number; +}; + +type FindOneRecordParams = { + objectRecordId: string | undefined; + onCompleted?: (data: T) => void; +}; + +export const useLazyFindOneRecord = ({ + objectNameSingular, + depth, +}: UseLazyFindOneRecordParams) => { + const { findOneRecordQuery } = useFindOneRecordQuery({ + objectNameSingular, + depth, + }); + + const [findOneRecord, { loading, error, data, called }] = + useLazyQuery(findOneRecordQuery); + + return { + findOneRecord: ({ objectRecordId, onCompleted }: FindOneRecordParams) => + findOneRecord({ + variables: { objectRecordId }, + onCompleted: (data) => { + const record = getRecordFromRecordNode({ + recordNode: data[objectNameSingular], + }); + onCompleted?.(record); + }, + }), + called, + error, + loading, + record: data?.[objectNameSingular] || undefined, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMapConnectionToRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useMapConnectionToRecords.ts deleted file mode 100644 index 03b599846183..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useMapConnectionToRecords.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { useCallback } from 'react'; -import { isNonEmptyArray } from '@sniptt/guards'; -import { produce } from 'immer'; -import { useRecoilValue } from 'recoil'; - -import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { parseFieldRelationType } from '@/object-metadata/utils/parseFieldRelationType'; -import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { FieldMetadataType } from '~/generated/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; - -export const useMapConnectionToRecords = () => { - const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - - const mapConnectionToRecords = useCallback( - ({ - objectRecordConnection, - objectNameSingular, - objectNamePlural, - depth, - }: { - objectRecordConnection: ObjectRecordConnection | undefined | null; - objectNameSingular?: string; - objectNamePlural?: string; - depth: number; - }): ObjectRecord[] => { - if ( - !isNonNullable(objectRecordConnection) || - !isNonEmptyArray(objectMetadataItems) - ) { - return []; - } - - const currentLevelObjectMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.nameSingular === objectNameSingular || - objectMetadataItem.namePlural === objectNamePlural, - ); - - if (!currentLevelObjectMetadataItem) { - throw new Error( - `Could not find object metadata item for object name singular "${objectNameSingular}" in mapConnectionToRecords`, - ); - } - - const relationFields = currentLevelObjectMetadataItem.fields.filter( - (field) => field.type === FieldMetadataType.Relation, - ); - - const objectRecords = [ - ...(objectRecordConnection.edges?.map((edge) => edge.node) ?? []), - ]; - - return produce(objectRecords, (objectRecordsDraft) => { - for (const objectRecordDraft of objectRecordsDraft) { - for (const relationField of relationFields) { - const relationType = parseFieldRelationType(relationField); - - if ( - relationType === 'TO_ONE_OBJECT' || - relationType === 'FROM_ONE_OBJECT' - ) { - continue; - } - - const relatedObjectMetadataSingularName = - relationField.toRelationMetadata?.fromObjectMetadata - .nameSingular ?? - relationField.fromRelationMetadata?.toObjectMetadata - .nameSingular ?? - null; - - const relationFieldMetadataItem = objectMetadataItems.find( - (objectMetadataItem) => - objectMetadataItem.nameSingular === - relatedObjectMetadataSingularName, - ); - - if ( - !relationFieldMetadataItem || - !isNonNullable(relatedObjectMetadataSingularName) - ) { - throw new Error( - `Could not find relation object metadata item for object name plural ${relationField.name} in mapConnectionToRecords`, - ); - } - - const relationConnection = objectRecordDraft?.[ - relationField.name - ] as ObjectRecordConnection | undefined | null; - - if (!isNonNullable(relationConnection)) { - continue; - } - - const relationConnectionMappedToRecords = mapConnectionToRecords({ - objectRecordConnection: relationConnection, - objectNameSingular: relatedObjectMetadataSingularName, - depth: depth - 1, - }); - - (objectRecordDraft as any)[relationField.name] = - relationConnectionMappedToRecords; - } - } - }) as ObjectRecord[]; - }, - [objectMetadataItems], - ); - - return mapConnectionToRecords; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoardDeprecated.ts b/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoardDeprecated.ts deleted file mode 100644 index 84c6d3112339..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useObjectRecordBoardDeprecated.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { useCallback } from 'react'; -import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; - -import { Company } from '@/companies/types/Company'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; -import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { Opportunity } from '@/pipeline/types/Opportunity'; -import { PipelineStep } from '@/pipeline/types/PipelineStep'; - -import { useFindManyRecords } from './useFindManyRecords'; - -export const useObjectRecordBoardDeprecated = () => { - const objectNameSingular = 'opportunity'; - - const { objectMetadataItem: foundObjectMetadataItem } = useObjectMetadataItem( - { - objectNameSingular, - }, - ); - - const { - isBoardLoadedState, - boardFiltersState, - boardSortsState, - savedCompaniesState, - savedOpportunitiesState, - savedPipelineStepsState, - } = useRecordBoardDeprecatedScopedStates(); - - const setIsBoardLoaded = useSetRecoilState(isBoardLoadedState); - - const boardFilters = useRecoilValue(boardFiltersState); - const boardSorts = useRecoilValue(boardSortsState); - - const setSavedCompanies = useSetRecoilState(savedCompaniesState); - - const [savedOpportunities] = useRecoilState(savedOpportunitiesState); - - const [savedPipelineSteps, setSavedPipelineSteps] = useRecoilState( - savedPipelineStepsState, - ); - - const filter = turnObjectDropdownFilterIntoQueryFilter( - boardFilters, - foundObjectMetadataItem?.fields ?? [], - ); - const orderBy = turnSortsIntoOrderBy( - boardSorts, - foundObjectMetadataItem?.fields ?? [], - ); - - useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.PipelineStep, - filter, - onCompleted: useCallback( - (data: ObjectRecordConnection) => { - setSavedPipelineSteps(data.edges.map((edge) => edge.node)); - }, - [setSavedPipelineSteps], - ), - }); - - const { - records: opportunities, - loading, - fetchMoreRecords: fetchMoreOpportunities, - } = useFindManyRecords({ - skip: !savedPipelineSteps.length, - objectNameSingular: CoreObjectNameSingular.Opportunity, - filter, - orderBy, - onCompleted: useCallback(() => { - setIsBoardLoaded(true); - }, [setIsBoardLoaded]), - }); - - const { fetchMoreRecords: fetchMoreCompanies } = useFindManyRecords({ - skip: !savedOpportunities.length, - objectNameSingular: CoreObjectNameSingular.Company, - filter: { - id: { - in: savedOpportunities.map( - (opportunity) => opportunity.companyId || '', - ), - }, - }, - onCompleted: useCallback( - (data: ObjectRecordConnection) => { - setSavedCompanies(data.edges.map((edge) => edge.node)); - }, - [setSavedCompanies], - ), - }); - - return { - opportunities, - loading, - fetchMoreOpportunities, - fetchMoreCompanies, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts index c76431c24cc5..a9c50331ba31 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecord.ts @@ -3,29 +3,40 @@ import { useApolloClient } from '@apollo/client'; import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { useGenerateObjectRecordOptimisticResponse } from '@/object-record/cache/hooks/useGenerateObjectRecordOptimisticResponse'; -import { getUpdateOneRecordMutationResponseField } from '@/object-record/hooks/useGenerateUpdateOneRecordMutation'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord'; +import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; +import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; import { sanitizeRecordInput } from '@/object-record/utils/sanitizeRecordInput'; type useUpdateOneRecordProps = { objectNameSingular: string; + queryFields?: Record; + depth?: number; }; export const useUpdateOneRecord = < UpdatedObjectRecord extends ObjectRecord = ObjectRecord, >({ objectNameSingular, + queryFields, + depth = 1, }: useUpdateOneRecordProps) => { const apolloClient = useApolloClient(); - const { objectMetadataItem, updateOneRecordMutation, getRecordFromCache } = - useObjectMetadataItem({ objectNameSingular }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); - const { generateObjectRecordOptimisticResponse } = - useGenerateObjectRecordOptimisticResponse({ - objectMetadataItem, - }); + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular, + }); + + const { updateOneRecordMutation } = useUpdateOneRecordMutation({ + objectNameSingular, + }); const { objectMetadataItems } = useObjectMetadataItems(); @@ -36,17 +47,57 @@ export const useUpdateOneRecord = < idToUpdate: string; updateOneRecordInput: Partial>; }) => { + const sanitizedInput = { + ...sanitizeRecordInput({ + objectMetadataItem, + recordInput: updateOneRecordInput, + }), + }; + const cachedRecord = getRecordFromCache(idToUpdate); - const sanitizedUpdateOneRecordInput = sanitizeRecordInput({ + const cachedRecordWithConnection = getRecordNodeFromRecord({ + record: cachedRecord, + objectMetadataItem, + objectMetadataItems, + depth, + queryFields, + computeReferences: true, + }); + + const optimisticRecord = { + ...cachedRecord, + ...sanitizedInput, + ...{ id: idToUpdate }, + }; + + const optimisticRecordWithConnection = + getRecordNodeFromRecord({ + record: optimisticRecord, + objectMetadataItem, + objectMetadataItems, + depth, + queryFields, + computeReferences: true, + }); + + if (!optimisticRecordWithConnection || !cachedRecordWithConnection) { + return null; + } + + updateRecordFromCache({ + objectMetadataItems, objectMetadataItem, - recordInput: updateOneRecordInput, + cache: apolloClient.cache, + record: optimisticRecord, }); - const optimisticallyUpdatedRecord = generateObjectRecordOptimisticResponse({ - ...(cachedRecord ?? {}), - ...sanitizedUpdateOneRecordInput, - id: idToUpdate, + triggerUpdateRecordOptimisticEffect({ + cache: apolloClient.cache, + objectMetadataItem, + currentRecord: cachedRecordWithConnection, + updatedRecord: optimisticRecordWithConnection, + objectMetadataItems, }); const mutationResponseField = @@ -56,10 +107,7 @@ export const useUpdateOneRecord = < mutation: updateOneRecordMutation, variables: { idToUpdate, - input: sanitizedUpdateOneRecordInput, - }, - optimisticResponse: { - [mutationResponseField]: optimisticallyUpdatedRecord, + input: sanitizedInput, }, update: (cache, { data }) => { const record = data?.[mutationResponseField]; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts new file mode 100644 index 000000000000..04bf3967375e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/hooks/useUpdateOneRecordMutation.ts @@ -0,0 +1,53 @@ +import gql from 'graphql-tag'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation'; +import { getUpdateOneRecordMutationResponseField } from '@/object-record/utils/getUpdateOneRecordMutationResponseField'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useUpdateOneRecordMutation = ({ + objectNameSingular, + computeReferences = false, + depth, +}: { + objectNameSingular: string; + computeReferences?: boolean; + depth?: number; +}) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (isUndefinedOrNull(objectMetadataItem)) { + return { updateOneRecordMutation: EMPTY_MUTATION }; + } + + const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); + + const mutationResponseField = getUpdateOneRecordMutationResponseField( + objectMetadataItem.nameSingular, + ); + + const updateOneRecordMutation = gql` + mutation UpdateOne${capitalizedObjectName}($idToUpdate: UUID!, $input: ${capitalizedObjectName}UpdateInput!) { + ${mutationResponseField}(id: $idToUpdate, data: $input) ${mapObjectMetadataToGraphQLQuery( + { + objectMetadataItems, + objectMetadataItem, + depth, + computeReferences, + }, + )} + } + `; + + return { + updateOneRecordMutation, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useUpsertRecordFieldFromState.ts b/packages/twenty-front/src/modules/object-record/hooks/useUpsertRecordFieldFromState.ts deleted file mode 100644 index b89bb5e3b265..000000000000 --- a/packages/twenty-front/src/modules/object-record/hooks/useUpsertRecordFieldFromState.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; - -export const useUpsertRecordFieldFromState = () => - useRecoilCallback( - ({ set }) => - ({ - record, - fieldName, - }: { - record: T; - fieldName: F extends string ? F : never; - }) => - set( - recordStoreFamilySelector({ recordId: record.id, fieldName }), - (previousField) => - isDeeplyEqual(previousField, record[fieldName]) - ? previousField - : record[fieldName], - ), - [], - ); diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useFindManyRecordsForMultipleMetadataItems.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useFindManyRecordsForMultipleMetadataItems.ts new file mode 100644 index 000000000000..3640dee6a618 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useFindManyRecordsForMultipleMetadataItems.ts @@ -0,0 +1,44 @@ +import { useQuery } from '@apollo/client'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getRecordsFromRecordConnection } from '@/object-record/cache/utils/getRecordsFromRecordConnection'; +import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { MultiObjectRecordQueryResult } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; + +export const useFindManyRecordsForMultipleMetadataItems = ({ + objectMetadataItems, + skip = false, + depth = 2, +}: { + objectMetadataItems: ObjectMetadataItem[]; + skip: boolean; + depth?: number; +}) => { + const findManyQuery = useGenerateFindManyRecordsForMultipleMetadataItemsQuery( + { + targetObjectMetadataItems: objectMetadataItems, + depth, + }, + ); + + const { data } = useQuery( + findManyQuery ?? EMPTY_QUERY, + { + skip, + }, + ); + + const resultWithoutConnection = Object.fromEntries( + Object.entries(data ?? {}).map(([namePlural, objectRecordConnection]) => [ + namePlural, + getRecordsFromRecordConnection({ + recordConnection: objectRecordConnection, + }), + ]), + ); + + return { + result: resultWithoutConnection, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts new file mode 100644 index 000000000000..60b1ca38f086 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery.ts @@ -0,0 +1,92 @@ +import { gql } from '@apollo/client'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateFindManyRecordsForMultipleMetadataItemsQuery = ({ + targetObjectMetadataItems, + depth, +}: { + targetObjectMetadataItems: ObjectMetadataItem[]; + depth?: number; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + const capitalizedObjectNameSingulars = targetObjectMetadataItems.map( + ({ nameSingular }) => capitalize(nameSingular), + ); + + if (!isNonEmptyArray(capitalizedObjectNameSingulars)) { + return null; + } + + const filterPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$filter${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}FilterInput`, + ) + .join(', '); + + const orderByPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$orderBy${capitalizedObjectNameSingular}: ${capitalizedObjectNameSingular}OrderByInput`, + ) + .join(', '); + + const lastCursorPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$lastCursor${capitalizedObjectNameSingular}: String`, + ) + .join(', '); + + const limitPerMetadataItemArray = capitalizedObjectNameSingulars + .map( + (capitalizedObjectNameSingular) => + `$limit${capitalizedObjectNameSingular}: Float`, + ) + .join(', '); + + return gql` + query FindManyRecordsMultipleMetadataItems( + ${filterPerMetadataItemArray}, + ${orderByPerMetadataItemArray}, + ${lastCursorPerMetadataItemArray}, + ${limitPerMetadataItemArray} + ) { + ${targetObjectMetadataItems + .map( + (objectMetadataItem) => + `${objectMetadataItem.namePlural}(filter: $filter${capitalize( + objectMetadataItem.nameSingular, + )}, orderBy: $orderBy${capitalize( + objectMetadataItem.nameSingular, + )}, first: $limit${capitalize( + objectMetadataItem.nameSingular, + )}, after: $lastCursor${capitalize( + objectMetadataItem.nameSingular, + )}){ + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: objectMetadataItems, + objectMetadataItem, + depth, + })} + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + 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 0de22274d78e..1fb257f79baf 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,6 +1,7 @@ +import { IconPlus } 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 { IconPlus } from '@/ui/display/icon'; import { LightButton } from '@/ui/input/button/components/LightButton'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; 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 67dc3c3d4d48..1ab04fd6820e 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,13 +1,16 @@ -import { ObjectFilterDropdownRecordSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchInput'; +import { useRecoilValue } from 'recoil'; + +import { ObjectFilterDropdownSearchInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { MultipleFiltersDropdownFilterOnFilterChangedEffect } from './MultipleFiltersDropdownFilterOnFilterChangedEffect'; -import { ObjectFilterDropdownDateSearchInput } from './ObjectFilterDropdownDateSearchInput'; +import { ObjectFilterDropdownDateInput } from './ObjectFilterDropdownDateInput'; import { ObjectFilterDropdownFilterSelect } from './ObjectFilterDropdownFilterSelect'; -import { ObjectFilterDropdownNumberSearchInput } from './ObjectFilterDropdownNumberSearchInput'; +import { ObjectFilterDropdownNumberInput } from './ObjectFilterDropdownNumberInput'; import { ObjectFilterDropdownOperandButton } from './ObjectFilterDropdownOperandButton'; import { ObjectFilterDropdownOperandSelect } from './ObjectFilterDropdownOperandSelect'; +import { ObjectFilterDropdownOptionSelect } from './ObjectFilterDropdownOptionSelect'; import { ObjectFilterDropdownRecordSelect } from './ObjectFilterDropdownRecordSelect'; import { ObjectFilterDropdownTextSearchInput } from './ObjectFilterDropdownTextSearchInput'; @@ -19,11 +22,21 @@ export const MultipleFiltersDropdownContent = ({ filterDropdownId, }: MultipleFiltersDropdownContentProps) => { const { - isObjectFilterDropdownOperandSelectUnfolded, - filterDefinitionUsedInDropdown, - selectedOperandInDropdown, + isObjectFilterDropdownOperandSelectUnfoldedState, + filterDefinitionUsedInDropdownState, + selectedOperandInDropdownState, } = useFilterDropdown({ filterDropdownId }); + const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue( + isObjectFilterDropdownOperandSelectUnfoldedState, + ); + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + return ( <> {!filterDefinitionUsedInDropdown ? ( @@ -35,22 +48,36 @@ export const MultipleFiltersDropdownContent = ({ <> - {['TEXT', 'EMAIL', 'PHONE', 'FULL_NAME', 'LINK'].includes( - filterDefinitionUsedInDropdown.type, - ) && } + {[ + 'TEXT', + 'EMAIL', + 'PHONE', + 'FULL_NAME', + 'LINK', + 'ADDRESS', + ].includes(filterDefinitionUsedInDropdown.type) && ( + + )} {['NUMBER', 'CURRENCY'].includes( filterDefinitionUsedInDropdown.type, - ) && } + ) && } {filterDefinitionUsedInDropdown.type === 'DATE_TIME' && ( - + )} {filterDefinitionUsedInDropdown.type === 'RELATION' && ( <> - + )} + {filterDefinitionUsedInDropdown.type === 'SELECT' && ( + <> + + + + + )} ) )} diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownButton.tsx index 0a4a16e62b41..3b5672783714 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownButton.tsx @@ -1,3 +1,5 @@ +import { useRecoilValue } from 'recoil'; + import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; @@ -14,9 +16,14 @@ export const ObjectFilterDropdownButton = ({ filterDropdownId, hotkeyScope, }: ObjectFilterDropdownButtonProps) => { - const { availableFilterDefinitions } = useFilterDropdown({ + const { availableFilterDefinitionsState } = useFilterDropdown({ filterDropdownId: filterDropdownId, }); + + const availableFilterDefinitions = useRecoilValue( + availableFilterDefinitionsState, + ); + const hasOnlyOneEntityFilter = availableFilterDefinitions.length === 1 && availableFilterDefinitions[0].type === 'RELATION'; 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 new file mode 100644 index 000000000000..11c9f1ab2820 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx @@ -0,0 +1,43 @@ +import { useRecoilValue } from 'recoil'; + +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; +import { isDefined } from '~/utils/isDefined'; + +export const ObjectFilterDropdownDateInput = () => { + const { + filterDefinitionUsedInDropdownState, + selectedOperandInDropdownState, + setIsObjectFilterDropdownUnfolded, + selectFilter, + } = useFilterDropdown(); + + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + + const handleChange = (date: Date | null) => { + if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return; + + selectFilter?.({ + fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, + value: isDefined(date) ? date.toISOString() : '', + operand: selectedOperandInDropdown, + displayValue: isDefined(date) ? date.toLocaleString() : '', + definition: filterDefinitionUsedInDropdown, + }); + + setIsObjectFilterDropdownUnfolded(false); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateSearchInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateSearchInput.tsx deleted file mode 100644 index f397312f4b2b..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateSearchInput.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; -import { isNonNullable } from '~/utils/isNonNullable'; - -export const ObjectFilterDropdownDateSearchInput = () => { - const { - filterDefinitionUsedInDropdown, - selectedOperandInDropdown, - setIsObjectFilterDropdownUnfolded, - selectFilter, - } = useFilterDropdown(); - - const handleChange = (date: Date | null) => { - if (!filterDefinitionUsedInDropdown || !selectedOperandInDropdown) return; - - selectFilter?.({ - fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, - value: isNonNullable(date) ? date.toISOString() : '', - operand: selectedOperandInDropdown, - displayValue: isNonNullable(date) ? date.toLocaleString() : '', - definition: filterDefinitionUsedInDropdown, - }); - - setIsObjectFilterDropdownUnfolded(false); - }; - - return ( - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchInput.tsx deleted file mode 100644 index 19db840d2569..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchInput.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { ChangeEvent } from 'react'; - -import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; - -export const ObjectFilterDropdownRecordSearchInput = () => { - const { - filterDefinitionUsedInDropdown, - selectedOperandInDropdown, - objectFilterDropdownSearchInput, - setObjectFilterDropdownSearchInput, - } = useFilterDropdown(); - - return ( - filterDefinitionUsedInDropdown && - selectedOperandInDropdown && ( - ) => { - setObjectFilterDropdownSearchInput(event.target.value); - }} - /> - ) - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchSelect.tsx index 0d19879da2ca..27b155975058 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownEntitySearchSelect.tsx @@ -1,4 +1,5 @@ import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; @@ -15,13 +16,24 @@ export const ObjectFilterDropdownEntitySearchSelect = ({ }) => { const { setObjectFilterDropdownSelectedEntityId, - filterDefinitionUsedInDropdown, - selectedOperandInDropdown, - objectFilterDropdownSearchInput, - selectedFilter, + filterDefinitionUsedInDropdownState, + selectedOperandInDropdownState, + objectFilterDropdownSearchInputState, + selectedFilterState, selectFilter, } = useFilterDropdown(); + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + const objectFilterDropdownSearchInput = useRecoilValue( + objectFilterDropdownSearchInputState, + ); + const selectedFilter = useRecoilValue(selectedFilterState); + const { closeDropdown } = useDropdown(OBJECT_FILTER_DROPDOWN_ID); const [isAllEntitySelected, setIsAllEntitySelected] = useState(false); 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 93bc71a1be4b..676fcb9a32bb 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,3 +1,5 @@ +import { useRecoilValue } from 'recoil'; + import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; @@ -12,9 +14,13 @@ export const ObjectFilterDropdownFilterSelect = () => { setFilterDefinitionUsedInDropdown, setSelectedOperandInDropdown, setObjectFilterDropdownSearchInput, - availableFilterDefinitions, + availableFilterDefinitionsState, } = useFilterDropdown(); + const availableFilterDefinitions = useRecoilValue( + availableFilterDefinitionsState, + ); + const { getIcon } = useIcons(); const setHotkeyScope = useSetHotkeyScope(); 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 new file mode 100644 index 000000000000..d6cc92eaaf1f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput.tsx @@ -0,0 +1,40 @@ +import { ChangeEvent } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; + +export const ObjectFilterDropdownNumberInput = () => { + const { + selectedOperandInDropdownState, + filterDefinitionUsedInDropdownState, + selectFilter, + } = useFilterDropdown(); + + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + + return ( + filterDefinitionUsedInDropdown && + selectedOperandInDropdown && ( + ) => { + selectFilter?.({ + fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, + value: event.target.value, + operand: selectedOperandInDropdown, + displayValue: event.target.value, + definition: filterDefinitionUsedInDropdown, + }); + }} + /> + ) + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberSearchInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberSearchInput.tsx deleted file mode 100644 index e99411065a5f..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberSearchInput.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { ChangeEvent } from 'react'; - -import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; - -export const ObjectFilterDropdownNumberSearchInput = () => { - const { - selectedOperandInDropdown, - filterDefinitionUsedInDropdown, - selectFilter, - } = useFilterDropdown(); - - return ( - filterDefinitionUsedInDropdown && - selectedOperandInDropdown && ( - ) => { - selectFilter?.({ - fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, - value: event.target.value, - operand: selectedOperandInDropdown, - displayValue: event.target.value, - definition: filterDefinitionUsedInDropdown, - }); - }} - /> - ) - ); -}; 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 7c5e0b251353..4aa675ec3539 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 @@ -1,16 +1,25 @@ +import { useRecoilValue } from 'recoil'; +import { IconChevronDown } from 'twenty-ui'; + import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { IconChevronDown } from '@/ui/display/icon'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { getOperandLabel } from '../utils/getOperandLabel'; export const ObjectFilterDropdownOperandButton = () => { const { - selectedOperandInDropdown, + selectedOperandInDropdownState, setIsObjectFilterDropdownOperandSelectUnfolded, - isObjectFilterDropdownOperandSelectUnfolded, + isObjectFilterDropdownOperandSelectUnfoldedState, } = useFilterDropdown(); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue( + isObjectFilterDropdownOperandSelectUnfoldedState, + ); + if (isObjectFilterDropdownOperandSelectUnfolded) { return null; } 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 c9d604d1d2b6..d3f0ccccfd6a 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 @@ -1,21 +1,34 @@ +import { useRecoilValue } from 'recoil'; + 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 { isDefined } from '~/utils/isDefined'; import { getOperandLabel } from '../utils/getOperandLabel'; import { getOperandsForFilterType } from '../utils/getOperandsForFilterType'; export const ObjectFilterDropdownOperandSelect = () => { const { - filterDefinitionUsedInDropdown, + filterDefinitionUsedInDropdownState, setSelectedOperandInDropdown, - isObjectFilterDropdownOperandSelectUnfolded, + isObjectFilterDropdownOperandSelectUnfoldedState, setIsObjectFilterDropdownOperandSelectUnfolded, - selectedFilter, + selectedFilterState, selectFilter, } = useFilterDropdown(); + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + + const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue( + isObjectFilterDropdownOperandSelectUnfoldedState, + ); + + const selectedFilter = useRecoilValue(selectedFilterState); + const operandsForFilterType = getOperandsForFilterType( filterDefinitionUsedInDropdown?.type, ); @@ -24,7 +37,10 @@ export const ObjectFilterDropdownOperandSelect = () => { setSelectedOperandInDropdown(newOperand); setIsObjectFilterDropdownOperandSelectUnfolded(false); - if (filterDefinitionUsedInDropdown && selectedFilter) { + if ( + isDefined(filterDefinitionUsedInDropdown) && + isDefined(selectedFilter) + ) { selectFilter?.({ fieldMetadataId: selectedFilter.fieldMetadataId, displayValue: selectedFilter.displayValue, 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 new file mode 100644 index 000000000000..46b294b1ebd8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx @@ -0,0 +1,133 @@ +import { useEffect, useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { useOptionsForSelect } from '@/object-record/object-filter-dropdown/hooks/useOptionsForSelect'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { MenuItemMultiSelect } from '@/ui/navigation/menu-item/components/MenuItemMultiSelect'; +import { isDefined } from '~/utils/isDefined'; + +export const EMPTY_FILTER_VALUE = ''; +export const MAX_OPTIONS_TO_DISPLAY = 3; + +type SelectOptionForFilter = FieldMetadataItemOption & { + isSelected: boolean; +}; + +export const ObjectFilterDropdownOptionSelect = () => { + const { + filterDefinitionUsedInDropdownState, + objectFilterDropdownSearchInputState, + selectedOperandInDropdownState, + objectFilterDropdownSelectedOptionValuesState, + selectFilter, + } = useFilterDropdown(); + + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + const objectFilterDropdownSearchInput = useRecoilValue( + objectFilterDropdownSearchInputState, + ); + const objectFilterDropdownSelectedOptionValues = useRecoilValue( + objectFilterDropdownSelectedOptionValuesState, + ); + + const fieldMetaDataId = filterDefinitionUsedInDropdown?.fieldMetadataId ?? ''; + + const { selectOptions } = useOptionsForSelect(fieldMetaDataId); + + const [selectableOptions, setSelectableOptions] = useState< + SelectOptionForFilter[] + >([]); + + useEffect(() => { + if (isDefined(selectOptions)) { + const options = selectOptions.map((option) => { + const isSelected = + objectFilterDropdownSelectedOptionValues?.includes(option.value) ?? + false; + + return { + ...option, + isSelected, + }; + }); + + setSelectableOptions(options); + } + }, [objectFilterDropdownSelectedOptionValues, selectOptions]); + + const handleMultipleOptionSelectChange = ( + optionChanged: SelectOptionForFilter, + isSelected: boolean, + ) => { + if (!selectOptions) { + return; + } + + const newSelectableOptions = selectableOptions.map((option) => + option.id === optionChanged.id ? { ...option, isSelected } : option, + ); + + setSelectableOptions(newSelectableOptions); + + const selectedOptions = newSelectableOptions.filter( + (option) => option.isSelected, + ); + + const filterDisplayValue = + selectedOptions.length > MAX_OPTIONS_TO_DISPLAY + ? `${selectedOptions.length} options` + : selectedOptions.map((option) => option.label).join(', '); + + if ( + isDefined(filterDefinitionUsedInDropdown) && + isDefined(selectedOperandInDropdown) + ) { + const newFilterValue = + selectedOptions.length > 0 + ? JSON.stringify(selectedOptions.map((option) => option.value)) + : EMPTY_FILTER_VALUE; + + selectFilter({ + definition: filterDefinitionUsedInDropdown, + operand: selectedOperandInDropdown, + displayValue: filterDisplayValue, + fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, + value: newFilterValue, + }); + } + }; + + const optionsInDropdown = selectableOptions?.filter((option) => + option.label + .toLowerCase() + .includes(objectFilterDropdownSearchInput.toLowerCase()), + ); + + const showNoResult = optionsInDropdown?.length === 0; + + return ( + + {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/ObjectFilterDropdownRecordRemoveFilterMenuItem.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordRemoveFilterMenuItem.tsx index 6cf124fd3b08..74f3ed364fa3 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,9 @@ -import { MenuItem } from 'tsup.ui.index'; +import { IconFilterOff } from 'twenty-ui'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { IconFilterOff } from '@/ui/display/icon'; 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 c711e872c715..65a2c3263dc3 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 @@ -1,22 +1,38 @@ +import { useRecoilValue } from 'recoil'; + import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { MultipleRecordSelectDropdown } from '@/object-record/select/components/MultipleRecordSelectDropdown'; import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect'; import { SelectableRecord } from '@/object-record/select/types/SelectableRecord'; +import { isDefined } from '~/utils/isDefined'; export const EMPTY_FILTER_VALUE = '[]'; export const MAX_RECORDS_TO_DISPLAY = 3; export const ObjectFilterDropdownRecordSelect = () => { const { - filterDefinitionUsedInDropdown, - objectFilterDropdownSearchInput, - selectedOperandInDropdown, + filterDefinitionUsedInDropdownState, + objectFilterDropdownSearchInputState, + selectedOperandInDropdownState, setObjectFilterDropdownSelectedRecordIds, - objectFilterDropdownSelectedRecordIds, + objectFilterDropdownSelectedRecordIdsState, selectFilter, emptyFilterButKeepDefinition, } = useFilterDropdown(); + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const objectFilterDropdownSearchInput = useRecoilValue( + objectFilterDropdownSearchInputState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + const objectFilterDropdownSelectedRecordIds = useRecoilValue( + objectFilterDropdownSelectedRecordIdsState, + ); + const objectNameSingular = filterDefinitionUsedInDropdown?.relationObjectMetadataNameSingular ?? ''; @@ -66,7 +82,10 @@ export const ObjectFilterDropdownRecordSelect = () => { ? `${selectedRecordNames.length} companies` : selectedRecordNames.join(', '); - if (filterDefinitionUsedInDropdown && selectedOperandInDropdown) { + if ( + isDefined(filterDefinitionUsedInDropdown) && + isDefined(selectedOperandInDropdown) + ) { const newFilterValue = newSelectedRecordIds.length > 0 ? JSON.stringify(newSelectedRecordIds) diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput.tsx new file mode 100644 index 000000000000..d84d2e4dde8e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSearchInput.tsx @@ -0,0 +1,39 @@ +import { ChangeEvent } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; + +export const ObjectFilterDropdownSearchInput = () => { + const { + filterDefinitionUsedInDropdownState, + selectedOperandInDropdownState, + objectFilterDropdownSearchInputState, + setObjectFilterDropdownSearchInput, + } = useFilterDropdown(); + + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + const objectFilterDropdownSearchInput = useRecoilValue( + objectFilterDropdownSearchInputState, + ); + + return ( + filterDefinitionUsedInDropdown && + selectedOperandInDropdown && ( + ) => { + setObjectFilterDropdownSearchInput(event.target.value); + }} + /> + ) + ); +}; 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 999fd6403020..445d080ad699 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 @@ -1,18 +1,30 @@ import { ChangeEvent } from 'react'; +import { useRecoilValue } from 'recoil'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; export const ObjectFilterDropdownTextSearchInput = () => { const { - filterDefinitionUsedInDropdown, - selectedOperandInDropdown, - objectFilterDropdownSearchInput, + filterDefinitionUsedInDropdownState, + selectedOperandInDropdownState, + objectFilterDropdownSearchInputState, setObjectFilterDropdownSearchInput, - selectedFilter, + selectedFilterState, selectFilter, } = useFilterDropdown(); + const filterDefinitionUsedInDropdown = useRecoilValue( + filterDefinitionUsedInDropdownState, + ); + const selectedOperandInDropdown = useRecoilValue( + selectedOperandInDropdownState, + ); + const objectFilterDropdownSearchInput = useRecoilValue( + objectFilterDropdownSearchInputState, + ); + const selectedFilter = useRecoilValue(selectedFilterState); + return ( filterDefinitionUsedInDropdown && selectedOperandInDropdown && ( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx index aa978a47dc14..7ddbdb5f5d59 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/SingleEntityObjectFilterDropdownButton.tsx @@ -1,9 +1,10 @@ import React from 'react'; import { useTheme } from '@emotion/react'; +import { useRecoilValue } from 'recoil'; +import { IconChevronDown } from 'twenty-ui'; import { ObjectFilterDropdownRecordRemoveFilterMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordRemoveFilterMenuItem'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { IconChevronDown } from '@/ui/display/icon/index'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; @@ -13,8 +14,8 @@ import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { getOperandsForFilterType } from '../utils/getOperandsForFilterType'; import { GenericEntityFilterChip } from './GenericEntityFilterChip'; -import { ObjectFilterDropdownRecordSearchInput } from './ObjectFilterDropdownEntitySearchInput'; import { ObjectFilterDropdownRecordSelect } from './ObjectFilterDropdownRecordSelect'; +import { ObjectFilterDropdownSearchInput } from './ObjectFilterDropdownSearchInput'; export const SingleEntityObjectFilterDropdownButton = ({ hotkeyScope, @@ -22,12 +23,17 @@ export const SingleEntityObjectFilterDropdownButton = ({ hotkeyScope: HotkeyScope; }) => { const { - availableFilterDefinitions, - selectedFilter, + availableFilterDefinitionsState, + selectedFilterState, setFilterDefinitionUsedInDropdown, setSelectedOperandInDropdown, } = useFilterDropdown(); + const availableFilterDefinitions = useRecoilValue( + availableFilterDefinitionsState, + ); + const selectedFilter = useRecoilValue(selectedFilterState); + const availableFilter = availableFilterDefinitions[0]; React.useEffect(() => { @@ -66,7 +72,7 @@ export const SingleEntityObjectFilterDropdownButton = ({ } dropdownComponents={ <> - + 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 76316b9dcd8d..311764d5892a 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 @@ -1,8 +1,9 @@ import { expect } from '@storybook/test'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; +import { RecoilRoot, useRecoilState } from 'recoil'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { useFilterDropdownStates } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdownStates'; import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; @@ -31,10 +32,15 @@ const mockFilter: Filter = { describe('useFilterDropdown', () => { it('should set availableFilterDefinitions', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { availableFilterDefinitionsState } = + useFilterDropdownStates(filterDropdownId); + + const [availableFilterDefinitions, setAvailableFilterDefinitions] = + useRecoilState(availableFilterDefinitionsState); + return { availableFilterDefinitions, setAvailableFilterDefinitions }; + }, renderHookConfig); expect(result.current.availableFilterDefinitions).toEqual([]); @@ -50,10 +56,14 @@ describe('useFilterDropdown', () => { }); it('should set onFilterSelect', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { onFilterSelectState } = useFilterDropdownStates(filterDropdownId); + + const [onFilterSelect, setOnFilterSelect] = + useRecoilState(onFilterSelectState); + return { onFilterSelect, setOnFilterSelect }; + }, renderHookConfig); expect(result.current.onFilterSelect).toBeUndefined(); @@ -68,10 +78,16 @@ describe('useFilterDropdown', () => { }); it('should set selectedOperandInDropdown', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { selectedOperandInDropdownState } = + useFilterDropdownStates(filterDropdownId); + + const [selectedOperandInDropdown, setSelectedOperandInDropdown] = + useRecoilState(selectedOperandInDropdownState); + return { selectedOperandInDropdown, setSelectedOperandInDropdown }; + }, renderHookConfig); + const mockOperand = ViewFilterOperand.Contains; expect(result.current.selectedOperandInDropdown).toBeNull(); @@ -84,10 +100,14 @@ describe('useFilterDropdown', () => { }); it('should set selectedFilter', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { selectedFilterState } = useFilterDropdownStates(filterDropdownId); + + const [selectedFilter, setSelectedFilter] = + useRecoilState(selectedFilterState); + return { selectedFilter, setSelectedFilter }; + }, renderHookConfig); expect(result.current.selectedFilter).toBeUndefined(); @@ -101,10 +121,20 @@ describe('useFilterDropdown', () => { }); it('should set filterDefinitionUsedInDropdown', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { filterDefinitionUsedInDropdownState } = + useFilterDropdownStates(filterDropdownId); + + const [ + filterDefinitionUsedInDropdown, + setFilterDefinitionUsedInDropdown, + ] = useRecoilState(filterDefinitionUsedInDropdownState); + return { + filterDefinitionUsedInDropdown, + setFilterDefinitionUsedInDropdown, + }; + }, renderHookConfig); expect(result.current.filterDefinitionUsedInDropdown).toBeNull(); @@ -121,10 +151,20 @@ describe('useFilterDropdown', () => { it('should set objectFilterDropdownSearchInput', async () => { const mockResult = 'value'; - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { objectFilterDropdownSearchInputState } = + useFilterDropdownStates(filterDropdownId); + + const [ + objectFilterDropdownSearchInput, + setObjectFilterDropdownSearchInput, + ] = useRecoilState(objectFilterDropdownSearchInputState); + return { + objectFilterDropdownSearchInput, + setObjectFilterDropdownSearchInput, + }; + }, renderHookConfig); expect(result.current.objectFilterDropdownSearchInput).toBe(''); @@ -139,10 +179,20 @@ describe('useFilterDropdown', () => { it('should set objectFilterDropdownSelectedEntityId', async () => { const mockResult = 'value'; - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { objectFilterDropdownSelectedEntityIdState } = + useFilterDropdownStates(filterDropdownId); + + const [ + objectFilterDropdownSelectedEntityId, + setObjectFilterDropdownSelectedEntityId, + ] = useRecoilState(objectFilterDropdownSelectedEntityIdState); + return { + objectFilterDropdownSelectedEntityId, + setObjectFilterDropdownSelectedEntityId, + }; + }, renderHookConfig); expect(result.current.objectFilterDropdownSelectedEntityId).toBeNull(); @@ -159,10 +209,20 @@ describe('useFilterDropdown', () => { it('should set objectFilterDropdownSelectedRecordIds', async () => { const mockResult = ['id-0', 'id-1', 'id-2']; - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { objectFilterDropdownSelectedRecordIdsState } = + useFilterDropdownStates(filterDropdownId); + + const [ + objectFilterDropdownSelectedRecordIds, + setObjectFilterDropdownSelectedRecordIds, + ] = useRecoilState(objectFilterDropdownSelectedRecordIdsState); + return { + objectFilterDropdownSelectedRecordIds, + setObjectFilterDropdownSelectedRecordIds, + }; + }, renderHookConfig); expect(result.current.objectFilterDropdownSelectedRecordIds).toHaveLength( 0, @@ -180,10 +240,20 @@ describe('useFilterDropdown', () => { }); it('should set isObjectFilterDropdownOperandSelectUnfolded', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { isObjectFilterDropdownOperandSelectUnfoldedState } = + useFilterDropdownStates(filterDropdownId); + + const [ + isObjectFilterDropdownOperandSelectUnfolded, + setIsObjectFilterDropdownOperandSelectUnfolded, + ] = useRecoilState(isObjectFilterDropdownOperandSelectUnfoldedState); + return { + isObjectFilterDropdownOperandSelectUnfolded, + setIsObjectFilterDropdownOperandSelectUnfolded, + }; + }, renderHookConfig); expect(result.current.isObjectFilterDropdownOperandSelectUnfolded).toBe( false, @@ -201,10 +271,20 @@ describe('useFilterDropdown', () => { }); it('should set isObjectFilterDropdownUnfolded', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useFilterDropdown({ filterDropdownId }); + const { isObjectFilterDropdownUnfoldedState } = + useFilterDropdownStates(filterDropdownId); + + const [ + isObjectFilterDropdownUnfolded, + setIsObjectFilterDropdownUnfolded, + ] = useRecoilState(isObjectFilterDropdownUnfoldedState); + return { + isObjectFilterDropdownUnfolded, + setIsObjectFilterDropdownUnfolded, + }; + }, renderHookConfig); expect(result.current.isObjectFilterDropdownUnfolded).toBe(false); @@ -218,10 +298,16 @@ describe('useFilterDropdown', () => { }); it('should reset filter', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + const { selectFilter, resetFilter } = useFilterDropdown({ + filterDropdownId, + }); + const { selectedFilterState } = useFilterDropdownStates(filterDropdownId); + + const [selectedFilter, setSelectedFilter] = + useRecoilState(selectedFilterState); + return { selectedFilter, setSelectedFilter, selectFilter, resetFilter }; + }, renderHookConfig); act(() => { result.current.selectFilter(mockFilter); @@ -241,10 +327,14 @@ describe('useFilterDropdown', () => { }); it('should call onFilterSelect when a filter option is set', async () => { - const { result } = renderHook( - () => useFilterDropdown({ filterDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + const { selectFilter } = useFilterDropdown({ filterDropdownId }); + const { onFilterSelectState } = useFilterDropdownStates(filterDropdownId); + + const [onFilterSelect, setOnFilterSelect] = + useRecoilState(onFilterSelectState); + return { onFilterSelect, setOnFilterSelect, selectFilter }; + }, renderHookConfig); const onFilterSelectMock = jest.fn(); expect(result.current.onFilterSelect).toBeUndefined(); @@ -261,7 +351,7 @@ describe('useFilterDropdown', () => { }); it('should handle scopeId undefined on initial values', () => { - console.error = jest.fn(); + global.console.error = jest.fn(); const renderFunction = () => { renderHook(() => useFilterDropdown(), renderHookConfig); 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 2b4932cbadf5..945d19c68954 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 @@ -1,7 +1,8 @@ -import { useCallback } from 'react'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; import { useFilterDropdownStates } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdownStates'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { ObjectFilterDropdownScopeInternalContext } from '../scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext'; import { Filter } from '../types/Filter'; @@ -17,88 +18,122 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => { ); const { - availableFilterDefinitions, - setAvailableFilterDefinitions, - filterDefinitionUsedInDropdown, - setFilterDefinitionUsedInDropdown, - objectFilterDropdownSearchInput, - setObjectFilterDropdownSearchInput, - objectFilterDropdownSelectedEntityId, - setObjectFilterDropdownSelectedEntityId, - objectFilterDropdownSelectedRecordIds, - setObjectFilterDropdownSelectedRecordIds, - isObjectFilterDropdownOperandSelectUnfolded, - setIsObjectFilterDropdownOperandSelectUnfolded, - isObjectFilterDropdownUnfolded, - setIsObjectFilterDropdownUnfolded, - selectedFilter, - setSelectedFilter, - selectedOperandInDropdown, - setSelectedOperandInDropdown, - onFilterSelect, - setOnFilterSelect, + availableFilterDefinitionsState, + filterDefinitionUsedInDropdownState, + objectFilterDropdownSearchInputState, + objectFilterDropdownSelectedEntityIdState, + objectFilterDropdownSelectedRecordIdsState, + objectFilterDropdownSelectedOptionValuesState, + isObjectFilterDropdownOperandSelectUnfoldedState, + isObjectFilterDropdownUnfoldedState, + selectedFilterState, + selectedOperandInDropdownState, + onFilterSelectState, } = useFilterDropdownStates(scopeId); - const selectFilter = useCallback( - (filter: Filter | null) => { - setSelectedFilter(filter); - onFilterSelect?.(filter); - }, - [setSelectedFilter, onFilterSelect], + const selectFilter = useRecoilCallback( + ({ set, snapshot }) => + (filter: Filter | null) => { + set(selectedFilterState, filter); + const onFilterSelect = getSnapshotValue(snapshot, onFilterSelectState); + + onFilterSelect?.(filter); + }, + [selectedFilterState, onFilterSelectState], ); - const emptyFilterButKeepDefinition = useCallback(() => { - setObjectFilterDropdownSearchInput(''); - setObjectFilterDropdownSelectedEntityId(null); - setObjectFilterDropdownSelectedRecordIds([]); - setSelectedFilter(undefined); - }, [ - setSelectedFilter, - setObjectFilterDropdownSelectedRecordIds, - setObjectFilterDropdownSelectedEntityId, - setObjectFilterDropdownSearchInput, - ]); + const emptyFilterButKeepDefinition = useRecoilCallback( + ({ set }) => + () => { + set(objectFilterDropdownSearchInputState, ''); + set(objectFilterDropdownSelectedEntityIdState, null); + set(objectFilterDropdownSelectedRecordIdsState, []); + set(selectedFilterState, undefined); + }, + [ + objectFilterDropdownSearchInputState, + objectFilterDropdownSelectedEntityIdState, + objectFilterDropdownSelectedRecordIdsState, + selectedFilterState, + ], + ); - const resetFilter = useCallback(() => { - setObjectFilterDropdownSearchInput(''); - setObjectFilterDropdownSelectedEntityId(null); - setObjectFilterDropdownSelectedRecordIds([]); - setSelectedFilter(undefined); - setFilterDefinitionUsedInDropdown(null); - setSelectedOperandInDropdown(null); - }, [ - setFilterDefinitionUsedInDropdown, - setObjectFilterDropdownSearchInput, - setObjectFilterDropdownSelectedEntityId, - setObjectFilterDropdownSelectedRecordIds, - setSelectedFilter, - setSelectedOperandInDropdown, - ]); + const resetFilter = useRecoilCallback( + ({ set }) => + () => { + set(objectFilterDropdownSearchInputState, ''); + set(objectFilterDropdownSelectedEntityIdState, null); + set(objectFilterDropdownSelectedRecordIdsState, []); + set(selectedFilterState, undefined); + set(filterDefinitionUsedInDropdownState, null); + set(selectedOperandInDropdownState, null); + }, + [ + filterDefinitionUsedInDropdownState, + objectFilterDropdownSearchInputState, + objectFilterDropdownSelectedEntityIdState, + objectFilterDropdownSelectedRecordIdsState, + selectedFilterState, + selectedOperandInDropdownState, + ], + ); + + const setAvailableFilterDefinitions = useSetRecoilState( + availableFilterDefinitionsState, + ); + const setSelectedFilter = useSetRecoilState(selectedFilterState); + const setSelectedOperandInDropdown = useSetRecoilState( + selectedOperandInDropdownState, + ); + const setFilterDefinitionUsedInDropdown = useSetRecoilState( + filterDefinitionUsedInDropdownState, + ); + const setObjectFilterDropdownSearchInput = useSetRecoilState( + objectFilterDropdownSearchInputState, + ); + const setObjectFilterDropdownSelectedEntityId = useSetRecoilState( + objectFilterDropdownSelectedEntityIdState, + ); + const setObjectFilterDropdownSelectedRecordIds = useSetRecoilState( + objectFilterDropdownSelectedRecordIdsState, + ); + const setObjectFilterDropdownSelectedOptionValues = useSetRecoilState( + objectFilterDropdownSelectedOptionValuesState, + ); + const setIsObjectFilterDropdownOperandSelectUnfolded = useSetRecoilState( + isObjectFilterDropdownOperandSelectUnfoldedState, + ); + const setIsObjectFilterDropdownUnfolded = useSetRecoilState( + isObjectFilterDropdownUnfoldedState, + ); + const setOnFilterSelect = useSetRecoilState(onFilterSelectState); return { scopeId, - availableFilterDefinitions, + selectFilter, + resetFilter, + setSelectedFilter, + setSelectedOperandInDropdown, setAvailableFilterDefinitions, - filterDefinitionUsedInDropdown, setFilterDefinitionUsedInDropdown, - objectFilterDropdownSearchInput, setObjectFilterDropdownSearchInput, - objectFilterDropdownSelectedEntityId, setObjectFilterDropdownSelectedEntityId, - objectFilterDropdownSelectedRecordIds, setObjectFilterDropdownSelectedRecordIds, - isObjectFilterDropdownOperandSelectUnfolded, + setObjectFilterDropdownSelectedOptionValues, setIsObjectFilterDropdownOperandSelectUnfolded, - isObjectFilterDropdownUnfolded, setIsObjectFilterDropdownUnfolded, - selectedFilter, - setSelectedFilter, - selectedOperandInDropdown, - setSelectedOperandInDropdown, - selectFilter, - resetFilter, - onFilterSelect, setOnFilterSelect, emptyFilterButKeepDefinition, + availableFilterDefinitionsState, + filterDefinitionUsedInDropdownState, + objectFilterDropdownSearchInputState, + objectFilterDropdownSelectedEntityIdState, + objectFilterDropdownSelectedRecordIdsState, + objectFilterDropdownSelectedOptionValuesState, + isObjectFilterDropdownOperandSelectUnfoldedState, + isObjectFilterDropdownUnfoldedState, + selectedFilterState, + selectedOperandInDropdownState, + onFilterSelectState, }; }; 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 3e6d2589145f..47a4835c1d18 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,86 +1,84 @@ -import { objectFilterDropdownSelectedRecordIdsScopedState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsScopedState'; -import { onFilterSelectScopedState } from '@/object-record/object-filter-dropdown/states/onFilterSelectScopedState'; -import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2'; - -import { availableFilterDefinitionsScopedState } from '../states/availableFilterDefinitionsScopedState'; -import { filterDefinitionUsedInDropdownScopedState } from '../states/filterDefinitionUsedInDropdownScopedState'; -import { isObjectFilterDropdownOperandSelectUnfoldedScopedState } from '../states/isObjectFilterDropdownOperandSelectUnfoldedScopedState'; -import { isObjectFilterDropdownUnfoldedScopedState } from '../states/isObjectFilterDropdownUnfoldedScopedState'; -import { objectFilterDropdownSearchInputScopedState } from '../states/objectFilterDropdownSearchInputScopedState'; -import { objectFilterDropdownSelectedEntityIdScopedState } from '../states/objectFilterDropdownSelectedEntityIdScopedState'; -import { selectedFilterScopedState } from '../states/selectedFilterScopedState'; -import { selectedOperandInDropdownScopedState } from '../states/selectedOperandInDropdownScopedState'; +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'; +import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState'; +import { objectFilterDropdownSelectedEntityIdComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdComponentState'; +import { objectFilterDropdownSelectedOptionValuesComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState'; +import { objectFilterDropdownSelectedRecordIdsComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState'; +import { onFilterSelectComponentState } from '@/object-record/object-filter-dropdown/states/onFilterSelectComponentState'; +import { selectedFilterComponentState } from '@/object-record/object-filter-dropdown/states/selectedFilterComponentState'; +import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; export const useFilterDropdownStates = (scopeId: string) => { - const [availableFilterDefinitions, setAvailableFilterDefinitions] = - useRecoilScopedStateV2(availableFilterDefinitionsScopedState, scopeId); + const availableFilterDefinitionsState = extractComponentState( + availableFilterDefinitionsComponentState, + scopeId, + ); - const [filterDefinitionUsedInDropdown, setFilterDefinitionUsedInDropdown] = - useRecoilScopedStateV2(filterDefinitionUsedInDropdownScopedState, scopeId); + const filterDefinitionUsedInDropdownState = extractComponentState( + filterDefinitionUsedInDropdownComponentState, + scopeId, + ); - const [objectFilterDropdownSearchInput, setObjectFilterDropdownSearchInput] = - useRecoilScopedStateV2(objectFilterDropdownSearchInputScopedState, scopeId); + const objectFilterDropdownSearchInputState = extractComponentState( + objectFilterDropdownSearchInputComponentState, + scopeId, + ); - const [ - objectFilterDropdownSelectedEntityId, - setObjectFilterDropdownSelectedEntityId, - ] = useRecoilScopedStateV2( - objectFilterDropdownSelectedEntityIdScopedState, + const objectFilterDropdownSelectedEntityIdState = extractComponentState( + objectFilterDropdownSelectedEntityIdComponentState, scopeId, ); - const [ - objectFilterDropdownSelectedRecordIds, - setObjectFilterDropdownSelectedRecordIds, - ] = useRecoilScopedStateV2( - objectFilterDropdownSelectedRecordIdsScopedState, + const objectFilterDropdownSelectedRecordIdsState = extractComponentState( + objectFilterDropdownSelectedRecordIdsComponentState, scopeId, ); - const [ - isObjectFilterDropdownOperandSelectUnfolded, - setIsObjectFilterDropdownOperandSelectUnfolded, - ] = useRecoilScopedStateV2( - isObjectFilterDropdownOperandSelectUnfoldedScopedState, + const objectFilterDropdownSelectedOptionValuesState = extractComponentState( + objectFilterDropdownSelectedOptionValuesComponentState, scopeId, ); - const [isObjectFilterDropdownUnfolded, setIsObjectFilterDropdownUnfolded] = - useRecoilScopedStateV2(isObjectFilterDropdownUnfoldedScopedState, scopeId); + const isObjectFilterDropdownOperandSelectUnfoldedState = + extractComponentState( + isObjectFilterDropdownOperandSelectUnfoldedComponentState, + scopeId, + ); + + const isObjectFilterDropdownUnfoldedState = extractComponentState( + isObjectFilterDropdownUnfoldedComponentState, + scopeId, + ); - const [selectedFilter, setSelectedFilter] = useRecoilScopedStateV2( - selectedFilterScopedState, + const selectedFilterState = extractComponentState( + selectedFilterComponentState, scopeId, ); - const [selectedOperandInDropdown, setSelectedOperandInDropdown] = - useRecoilScopedStateV2(selectedOperandInDropdownScopedState, scopeId); + const selectedOperandInDropdownState = extractComponentState( + selectedOperandInDropdownComponentState, + scopeId, + ); - const [onFilterSelect, setOnFilterSelect] = useRecoilScopedStateV2( - onFilterSelectScopedState, + const onFilterSelectState = extractComponentState( + onFilterSelectComponentState, scopeId, ); return { - availableFilterDefinitions, - setAvailableFilterDefinitions, - filterDefinitionUsedInDropdown, - setFilterDefinitionUsedInDropdown, - objectFilterDropdownSearchInput, - setObjectFilterDropdownSearchInput, - objectFilterDropdownSelectedEntityId, - setObjectFilterDropdownSelectedEntityId, - objectFilterDropdownSelectedRecordIds, - setObjectFilterDropdownSelectedRecordIds, - isObjectFilterDropdownOperandSelectUnfolded, - setIsObjectFilterDropdownOperandSelectUnfolded, - isObjectFilterDropdownUnfolded, - setIsObjectFilterDropdownUnfolded, - selectedFilter, - setSelectedFilter, - selectedOperandInDropdown, - setSelectedOperandInDropdown, - onFilterSelect, - setOnFilterSelect, + availableFilterDefinitionsState, + filterDefinitionUsedInDropdownState, + objectFilterDropdownSearchInputState, + objectFilterDropdownSelectedEntityIdState, + objectFilterDropdownSelectedRecordIdsState, + objectFilterDropdownSelectedOptionValuesState, + isObjectFilterDropdownOperandSelectUnfoldedState, + isObjectFilterDropdownUnfoldedState, + selectedFilterState, + selectedOperandInDropdownState, + onFilterSelectState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useOptionsForSelect.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useOptionsForSelect.ts new file mode 100644 index 000000000000..cf8440a10f9f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useOptionsForSelect.ts @@ -0,0 +1,28 @@ +import { useParams } from 'react-router-dom'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; + +export const DEFAULT_SEARCH_REQUEST_LIMIT = 60; + +export const useOptionsForSelect = (fieldMetadataId: string) => { + const objectNamePlural = useParams().objectNamePlural ?? ''; + + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.id === fieldMetadataId, + ); + + const selectOptions = fieldMetadataItem?.options; + + return { + selectOptions, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext.ts index 30869a8a6188..1c29844bd5f6 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext.ts @@ -1,7 +1,7 @@ -import { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; +import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; -type ObjectFilterDropdownScopeInternalContextProps = StateScopeMapKey; +type ObjectFilterDropdownScopeInternalContextProps = ComponentStateKey; export const ObjectFilterDropdownScopeInternalContext = createScopeInternalContext(); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts new file mode 100644 index 000000000000..9a3f649ea491 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsComponentState.ts @@ -0,0 +1,9 @@ +import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const availableFilterDefinitionsComponentState = createComponentState< + FilterDefinition[] +>({ + key: 'availableFilterDefinitionsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsScopedState.ts deleted file mode 100644 index 4ed3c9ce7778..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/availableFilterDefinitionsScopedState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const availableFilterDefinitionsScopedState = createStateScopeMap< - FilterDefinition[] ->({ - key: 'availableFilterDefinitionsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState.ts new file mode 100644 index 000000000000..c7fd89f92902 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState.ts @@ -0,0 +1,9 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { FilterDefinition } from '../types/FilterDefinition'; + +export const filterDefinitionUsedInDropdownComponentState = + createComponentState({ + key: 'filterDefinitionUsedInDropdownComponentState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownScopedState.ts deleted file mode 100644 index 1ee8f0b45d72..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownScopedState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { FilterDefinition } from '../types/FilterDefinition'; - -export const filterDefinitionUsedInDropdownScopedState = - createStateScopeMap({ - key: 'filterDefinitionUsedInDropdownScopedState', - defaultValue: null, - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedComponentState.ts new file mode 100644 index 000000000000..389c6d30f928 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isObjectFilterDropdownOperandSelectUnfoldedComponentState = + createComponentState({ + key: 'isObjectFilterDropdownOperandSelectUnfoldedComponentState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedScopedState.ts deleted file mode 100644 index bec9fee84d3b..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isObjectFilterDropdownOperandSelectUnfoldedScopedState = - createStateScopeMap({ - key: 'isObjectFilterDropdownOperandSelectUnfoldedScopedState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedComponentState.ts new file mode 100644 index 000000000000..cfd6dea10c8f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isObjectFilterDropdownUnfoldedComponentState = + createComponentState({ + key: 'isObjectFilterDropdownUnfoldedScopedState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedScopedState.ts deleted file mode 100644 index 432df3475552..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isObjectFilterDropdownUnfoldedScopedState = - createStateScopeMap({ - key: 'isObjectFilterDropdownUnfoldedScopedState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState.ts new file mode 100644 index 000000000000..8addf2fb6cc6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectFilterDropdownSearchInputComponentState = + createComponentState({ + key: 'objectFilterDropdownSearchInputComponentState', + defaultValue: '', + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputScopedState.ts deleted file mode 100644 index b5cd0c1e43ad..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const objectFilterDropdownSearchInputScopedState = - createStateScopeMap({ - key: 'objectFilterDropdownSearchInputScopedState', - defaultValue: '', - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdComponentState.ts new file mode 100644 index 000000000000..3f62c3d65b1b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectFilterDropdownSelectedEntityIdComponentState = + createComponentState({ + key: 'objectFilterDropdownSelectedEntityIdComponentState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdScopedState.ts deleted file mode 100644 index a96a2c5e94e5..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedEntityIdScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const objectFilterDropdownSelectedEntityIdScopedState = - createStateScopeMap({ - key: 'objectFilterDropdownSelectedEntityIdScopedState', - defaultValue: null, - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState.ts new file mode 100644 index 000000000000..feef87220c1e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedOptionValuesComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectFilterDropdownSelectedOptionValuesComponentState = + createComponentState({ + key: 'objectFilterDropdownSelectedOptionValuesComponentState', + defaultValue: [], + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState.ts new file mode 100644 index 000000000000..de23b3b0daa5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectFilterDropdownSelectedRecordIdsComponentState = + createComponentState({ + key: 'objectFilterDropdownSelectedRecordIdsComponentState', + defaultValue: [], + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsScopedState.ts deleted file mode 100644 index 10c1dab0b6a1..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSelectedRecordIdsScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const objectFilterDropdownSelectedRecordIdsScopedState = - createStateScopeMap({ - key: 'objectFilterDropdownSelectedRecordIdsScopedState', - defaultValue: [], - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/onFilterSelectComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/onFilterSelectComponentState.ts new file mode 100644 index 000000000000..b73c2cbe962f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/onFilterSelectComponentState.ts @@ -0,0 +1,10 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { Filter } from '../types/Filter'; + +export const onFilterSelectComponentState = createComponentState< + ((filter: Filter | null) => void) | undefined +>({ + key: 'onFilterSelectComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/onFilterSelectScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/onFilterSelectScopedState.ts deleted file mode 100644 index 04c2199ce928..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/onFilterSelectScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { Filter } from '../types/Filter'; - -export const onFilterSelectScopedState = createStateScopeMap< - ((filter: Filter | null) => void) | undefined ->({ - key: 'onFilterSelectScopedState', - defaultValue: undefined, -}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedFilterComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedFilterComponentState.ts new file mode 100644 index 000000000000..7b4183c74841 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedFilterComponentState.ts @@ -0,0 +1,10 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { Filter } from '../types/Filter'; + +export const selectedFilterComponentState = createComponentState< + Filter | undefined | null +>({ + key: 'selectedFilterComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedFilterScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedFilterScopedState.ts deleted file mode 100644 index 7e45134475fa..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedFilterScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { Filter } from '../types/Filter'; - -export const selectedFilterScopedState = createStateScopeMap< - Filter | undefined | null ->({ - key: 'selectedFilterScopedState', - defaultValue: undefined, -}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState.ts new file mode 100644 index 000000000000..eef8fe552a96 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; + +export const selectedOperandInDropdownComponentState = + createComponentState({ + key: 'selectedOperandInDropdownComponentState', + defaultValue: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedOperandInDropdownScopedState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedOperandInDropdownScopedState.ts deleted file mode 100644 index 2cb8e270c6b1..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/selectedOperandInDropdownScopedState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; -import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; - -export const selectedOperandInDropdownScopedState = - createStateScopeMap({ - key: 'selectedOperandInDropdownScopedState', - defaultValue: null, - }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts index 2c182f0a8bc1..7aa1bed31ec1 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterType.ts @@ -3,8 +3,12 @@ export type FilterType = | 'PHONE' | 'EMAIL' | 'DATE_TIME' + | 'DATE' | 'NUMBER' | 'CURRENCY' | 'FULL_NAME' | 'LINK' - | 'RELATION'; + | 'RELATION' + | 'ADDRESS' + | 'SELECT' + | 'MULTI_SELECT'; 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.tsx index 1486146d8170..7c6f09e6e118 100644 --- 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.tsx @@ -11,6 +11,7 @@ describe('getOperandsForFilterType', () => { 'FULL_NAME', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain], ], + ['ADDRESS', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], ['LINK', [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]], ['CURRENCY', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]], ['NUMBER', [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]], 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 cd70c9334ff1..741293e0d7ca 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 @@ -9,13 +9,16 @@ export const getOperandsForFilterType = ( case 'TEXT': case 'EMAIL': case 'FULL_NAME': + case 'ADDRESS': case 'LINK': return [ViewFilterOperand.Contains, ViewFilterOperand.DoesNotContain]; case 'CURRENCY': case 'NUMBER': case 'DATE_TIME': + case 'DATE': return [ViewFilterOperand.GreaterThan, ViewFilterOperand.LessThan]; case 'RELATION': + case 'SELECT': return [ViewFilterOperand.Is, ViewFilterOperand.IsNot]; default: return []; 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 4487463fa5e1..89da251bcdfc 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,21 +1,18 @@ -import { useCallback, useState } from 'react'; +import { IconChevronDown } from 'twenty-ui'; import { OBJECT_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ObjectSortDropdownId'; -import { useSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useSortDropdown'; +import { useObjectSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useObjectSortDropdown'; import { ObjectSortDropdownScope } from '@/object-record/object-sort-dropdown/scopes/ObjectSortDropdownScope'; -import { IconChevronDown } from '@/ui/display/icon'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { LightButton } from '@/ui/input/button/components/LightButton'; 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 { 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 { SortDefinition } from '../types/SortDefinition'; -import { SORT_DIRECTIONS, SortDirection } from '../types/SortDirection'; +import { SORT_DIRECTIONS } from '../types/SortDirection'; export type ObjectSortDropdownButtonProps = { sortDropdownId: string; @@ -26,39 +23,20 @@ export const ObjectSortDropdownButton = ({ sortDropdownId, hotkeyScope, }: ObjectSortDropdownButtonProps) => { - const [isSortDirectionMenuUnfolded, setIsSortDirectionMenuUnfolded] = - useState(false); - - const [selectedSortDirection, setSelectedSortDirection] = - useState('asc'); - - const resetState = useCallback(() => { - setIsSortDirectionMenuUnfolded(false); - setSelectedSortDirection('asc'); - }, []); - - const { isSortSelected } = useSortDropdown({ - sortDropdownId: sortDropdownId, - }); - - const { toggleDropdown } = useDropdown(OBJECT_SORT_DROPDOWN_ID); + const { + isSortDirectionMenuUnfolded, + setIsSortDirectionMenuUnfolded, + selectedSortDirection, + setSelectedSortDirection, + toggleSortDropdown, + resetState, + isSortSelected, + availableSortDefinitions, + handleAddSort, + } = useObjectSortDropdown(); const handleButtonClick = () => { - toggleDropdown(); - resetState(); - }; - - const { availableSortDefinitions, onSortSelect } = useSortDropdown({ - sortDropdownId: sortDropdownId, - }); - - const handleAddSort = (selectedSortDefinition: SortDefinition) => { - toggleDropdown(); - onSortSelect?.({ - fieldMetadataId: selectedSortDefinition.fieldMetadataId, - direction: selectedSortDirection, - definition: selectedSortDefinition, - }); + toggleSortDropdown(); }; const handleDropdownButtonClose = () => { diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/constants/ObjectSortDropdownId.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/constants/ObjectSortDropdownId.ts index 5cc1f9c8fc65..18e7cdb32164 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/constants/ObjectSortDropdownId.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/constants/ObjectSortDropdownId.ts @@ -1 +1,5 @@ -export const OBJECT_SORT_DROPDOWN_ID = 'sort-dropdown'; +/* eslint-disable @nx/workspace-max-consts-per-file */ +const OBJECT_SORT_DROPDOWN_ID = 'sort-dropdown'; +const VIEW_SORT_DROPDOWN_ID = 'view-sort'; + +export { OBJECT_SORT_DROPDOWN_ID, VIEW_SORT_DROPDOWN_ID }; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/__tests__/useSortDropdown.test.tsx b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/__tests__/useSortDropdown.test.tsx index 108055202573..6bf6ddf0d53e 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/__tests__/useSortDropdown.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/__tests__/useSortDropdown.test.tsx @@ -1,7 +1,9 @@ +import { expect } from '@storybook/test'; import { act, renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot } from 'recoil'; +import { RecoilRoot, useRecoilState } from 'recoil'; import { useSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useSortDropdown'; +import { useSortDropdownStates } from '@/object-record/object-sort-dropdown/hooks/useSortDropdownStates'; import { Sort } from '@/object-record/object-sort-dropdown/types/Sort'; import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition'; @@ -20,10 +22,19 @@ const sortDefinitions: SortDefinition[] = [ describe('useSortDropdown', () => { it('should set availableSortDefinitions', async () => { - const { result } = renderHook( - () => useSortDropdown({ sortDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useSortDropdown({ sortDropdownId }); + const { availableSortDefinitionsState } = + useSortDropdownStates(sortDropdownId); + + const [availableSortDefinitions, setAvailableSortDefinitions] = + useRecoilState(availableSortDefinitionsState); + + return { + availableSortDefinitions, + setAvailableSortDefinitions, + }; + }, renderHookConfig); expect(result.current.availableSortDefinitions).toEqual([]); act(() => { result.current.setAvailableSortDefinitions(sortDefinitions); @@ -35,10 +46,18 @@ describe('useSortDropdown', () => { }); it('should set isSortSelected', async () => { - const { result } = renderHook( - () => useSortDropdown({ sortDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useSortDropdown({ sortDropdownId }); + const { isSortSelectedState } = useSortDropdownStates(sortDropdownId); + + const [isSortSelected, setIsSortSelected] = + useRecoilState(isSortSelectedState); + + return { + isSortSelected, + setIsSortSelected, + }; + }, renderHookConfig); expect(result.current.isSortSelected).toBe(false); @@ -54,10 +73,17 @@ describe('useSortDropdown', () => { it('should set onSortSelect', async () => { const OnSortSelectFunction = () => {}; const mockOnSortSelect = jest.fn(() => OnSortSelectFunction); - const { result } = renderHook( - () => useSortDropdown({ sortDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useSortDropdown({ sortDropdownId }); + const { onSortSelectState } = useSortDropdownStates(sortDropdownId); + + const [onSortSelect, setOnSortSelect] = useRecoilState(onSortSelectState); + + return { + onSortSelect, + setOnSortSelect, + }; + }, renderHookConfig); expect(result.current.onSortSelect).toBeUndefined(); @@ -78,10 +104,17 @@ describe('useSortDropdown', () => { definition: sortDefinitions[0], }; - const { result } = renderHook( - () => useSortDropdown({ sortDropdownId }), - renderHookConfig, - ); + const { result } = renderHook(() => { + useSortDropdown({ sortDropdownId }); + const { onSortSelectState } = useSortDropdownStates(sortDropdownId); + + const [onSortSelect, setOnSortSelect] = useRecoilState(onSortSelectState); + + return { + onSortSelect, + setOnSortSelect, + }; + }, renderHookConfig); act(() => { result.current.setOnSortSelect(mockOnSortSelect); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useObjectSortDropdown.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useObjectSortDropdown.ts new file mode 100644 index 000000000000..85322c628b7b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useObjectSortDropdown.ts @@ -0,0 +1,77 @@ +import { useCallback } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { useSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useSortDropdown'; +import isSortDirectionMenuUnfoldedState from '@/object-record/object-sort-dropdown/states/isSortDirectionMenuUnfoldedState'; +import selectedSortDirectionState from '@/object-record/object-sort-dropdown/states/selectedSortDirectionState'; +import { SortDefinition } from '@/object-record/object-sort-dropdown/types/SortDefinition'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; + +import { + OBJECT_SORT_DROPDOWN_ID, + VIEW_SORT_DROPDOWN_ID, +} from '../constants/ObjectSortDropdownId'; + +// TODO: merge this with useSortDropdown +export const useObjectSortDropdown = () => { + const [isSortDirectionMenuUnfolded, setIsSortDirectionMenuUnfolded] = + useRecoilState(isSortDirectionMenuUnfoldedState); + + const [selectedSortDirection, setSelectedSortDirection] = useRecoilState( + selectedSortDirectionState, + ); + + const resetState = useCallback(() => { + setIsSortDirectionMenuUnfolded(false); + setSelectedSortDirection('asc'); + }, [setIsSortDirectionMenuUnfolded, setSelectedSortDirection]); + + const { toggleDropdown, closeDropdown } = useDropdown( + OBJECT_SORT_DROPDOWN_ID, + ); + + const toggleSortDropdown = () => { + toggleDropdown(); + resetState(); + }; + + const closeSortDropdown = () => { + closeDropdown(); + resetState(); + }; + + const { + availableSortDefinitionsState, + onSortSelectState, + isSortSelectedState, + } = useSortDropdown({ + sortDropdownId: VIEW_SORT_DROPDOWN_ID, + }); + + const isSortSelected = useRecoilValue(isSortSelectedState); + const availableSortDefinitions = useRecoilValue( + availableSortDefinitionsState, + ); + const onSortSelect = useRecoilValue(onSortSelectState); + + const handleAddSort = (selectedSortDefinition: SortDefinition) => { + closeSortDropdown(); + onSortSelect?.({ + fieldMetadataId: selectedSortDefinition.fieldMetadataId, + direction: selectedSortDirection, + definition: selectedSortDefinition, + }); + }; + + return { + isSortDirectionMenuUnfolded, + setIsSortDirectionMenuUnfolded, + selectedSortDirection, + setSelectedSortDirection, + toggleSortDropdown, + resetState, + isSortSelected, + availableSortDefinitions, + handleAddSort, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdown.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdown.ts index 696dae8cd631..aee777aa1217 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdown.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdown.ts @@ -13,21 +13,15 @@ export const useSortDropdown = (props?: UseSortProps) => { props?.sortDropdownId, ); const { - availableSortDefinitions, - setAvailableSortDefinitions, - isSortSelected, - setIsSortSelected, - onSortSelect, - setOnSortSelect, + availableSortDefinitionsState, + isSortSelectedState, + onSortSelectState, } = useSortDropdownStates(scopeId); return { scopeId, - availableSortDefinitions, - isSortSelected, - setIsSortSelected, - setAvailableSortDefinitions, - onSortSelect, - setOnSortSelect, + availableSortDefinitionsState, + isSortSelectedState, + onSortSelectState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdownStates.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdownStates.ts index 9453508fee46..74c6bdb5481a 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdownStates.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/hooks/useSortDropdownStates.ts @@ -1,29 +1,27 @@ -import { onSortSelectScopedState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState'; -import { useRecoilScopedStateV2 } from '@/ui/utilities/recoil-scope/hooks/useRecoilScopedStateV2'; - -import { availableSortDefinitionsScopedState } from '../states/availableSortDefinitionsScopedState'; -import { isSortSelectedScopedState } from '../states/isSortSelectedScopedState'; +import { isSortSelectedComponentState } from '@/object-record/object-sort-dropdown/states/isSortSelectedScopedState'; +import { onSortSelectComponentState } from '@/object-record/object-sort-dropdown/states/onSortSelectScopedState'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import { availableSortDefinitionsComponentState } from '@/views/states/availableSortDefinitionsComponentState'; export const useSortDropdownStates = (scopeId: string) => { - const [availableSortDefinitions, setAvailableSortDefinitions] = - useRecoilScopedStateV2(availableSortDefinitionsScopedState, scopeId); + const availableSortDefinitionsState = extractComponentState( + availableSortDefinitionsComponentState, + scopeId, + ); - const [isSortSelected, setIsSortSelected] = useRecoilScopedStateV2( - isSortSelectedScopedState, + const isSortSelectedState = extractComponentState( + isSortSelectedComponentState, scopeId, ); - const [onSortSelect, setOnSortSelect] = useRecoilScopedStateV2( - onSortSelectScopedState, + const onSortSelectState = extractComponentState( + onSortSelectComponentState, scopeId, ); return { - availableSortDefinitions, - setAvailableSortDefinitions, - isSortSelected, - setIsSortSelected, - onSortSelect, - setOnSortSelect, + availableSortDefinitionsState, + isSortSelectedState, + onSortSelectState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/scopes/scope-internal-context/ObjectSortDropdownScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/scopes/scope-internal-context/ObjectSortDropdownScopeInternalContext.ts index 45a636d5fcb2..8454813f3d87 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/scopes/scope-internal-context/ObjectSortDropdownScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/scopes/scope-internal-context/ObjectSortDropdownScopeInternalContext.ts @@ -1,9 +1,9 @@ -import { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; +import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; import { Sort } from '../../types/Sort'; -type ObjectSortDropdownScopeInternalContextProps = StateScopeMapKey & { +type ObjectSortDropdownScopeInternalContextProps = ComponentStateKey & { onSortSelect?: (sort: Sort) => void; }; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/availableSortDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/availableSortDefinitionsComponentState.ts new file mode 100644 index 000000000000..db559fa52f6b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/availableSortDefinitionsComponentState.ts @@ -0,0 +1,10 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { SortDefinition } from '../types/SortDefinition'; + +export const availableSortDefinitionsComponentState = createComponentState< + SortDefinition[] +>({ + key: 'availableSortDefinitionsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/availableSortDefinitionsScopedState.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/availableSortDefinitionsScopedState.ts deleted file mode 100644 index 27da40c6b74e..000000000000 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/availableSortDefinitionsScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { SortDefinition } from '../types/SortDefinition'; - -export const availableSortDefinitionsScopedState = createStateScopeMap< - SortDefinition[] ->({ - key: 'availableSortDefinitionsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortDirectionMenuUnfoldedState.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortDirectionMenuUnfoldedState.ts new file mode 100644 index 000000000000..ebf779b94a37 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortDirectionMenuUnfoldedState.ts @@ -0,0 +1,8 @@ +import { atom } from 'recoil'; + +const isSortDirectionMenuUnfoldedState = atom({ + key: 'isSortDirectionMenuUnfoldedState', + default: false, +}); + +export default isSortDirectionMenuUnfoldedState; diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortSelectedScopedState.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortSelectedScopedState.ts index 02514a4c4d4f..180a2f017221 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortSelectedScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/isSortSelectedScopedState.ts @@ -1,6 +1,6 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -export const isSortSelectedScopedState = createStateScopeMap({ - key: 'isSortSelectedScopedState', +export const isSortSelectedComponentState = createComponentState({ + key: 'isSortSelectedComponentState', defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/onSortSelectScopedState.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/onSortSelectScopedState.ts index 0b76031e6abb..8b6ca0a8cb35 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/onSortSelectScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/onSortSelectScopedState.ts @@ -1,10 +1,10 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; import { Sort } from '../types/Sort'; -export const onSortSelectScopedState = createStateScopeMap< +export const onSortSelectComponentState = createComponentState< ((sort: Sort) => void) | undefined >({ - key: 'onSortSelectScopedState', + key: 'onSortSelectComponentState', defaultValue: undefined, }); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/selectedSortDirectionState.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/selectedSortDirectionState.ts new file mode 100644 index 000000000000..327f9691752d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/states/selectedSortDirectionState.ts @@ -0,0 +1,10 @@ +import { atom } from 'recoil'; + +import { SortDirection } from '@/object-record/object-sort-dropdown/types/SortDirection'; + +const selectedSortDirectionState = atom({ + key: 'selectedSortDirectionState', + default: 'asc', +}); + +export default selectedSortDirectionState; 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.tsx index b46ee87ac717..5a9dc2de62a5 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.tsx @@ -55,7 +55,7 @@ describe('turnSortsIntoOrderBy', () => { }); }); - it('should throw error if field not found', () => { + it('should ignore if field not found', () => { const sorts: Sort[] = [ { fieldMetadataId: 'invalidField', @@ -63,8 +63,8 @@ describe('turnSortsIntoOrderBy', () => { definition: sortDefinition, }, ]; - expect(() => turnSortsIntoOrderBy(sorts, [])).toThrow( - 'Could not find field invalidField in metadata object', - ); + expect(turnSortsIntoOrderBy(sorts, [])).toEqual({ + position: 'AscNullsFirst', + }); }); }); diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts index fde1df2537b8..85452ddfcf65 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy.ts @@ -2,6 +2,8 @@ import { OrderBy } from '@/object-metadata/types/OrderBy'; import { OrderByField } from '@/object-metadata/types/OrderByField'; import { Field } from '~/generated/graphql'; import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { Sort } from '../types/Sort'; @@ -11,20 +13,20 @@ export const turnSortsIntoOrderBy = ( ): OrderByField => { const fieldsById = mapArrayToObject(fields, ({ id }) => id); const sortsOrderBy = Object.fromEntries( - sorts.map((sort) => { - const correspondingField = fieldsById[sort.fieldMetadataId]; + sorts + .map((sort) => { + const correspondingField = fieldsById[sort.fieldMetadataId]; - if (!correspondingField) { - throw new Error( - `Could not find field ${sort.fieldMetadataId} in metadata object`, - ); - } + if (isUndefinedOrNull(correspondingField)) { + return undefined; + } - const direction: OrderBy = - sort.direction === 'asc' ? 'AscNullsFirst' : 'DescNullsLast'; + const direction: OrderBy = + sort.direction === 'asc' ? 'AscNullsFirst' : 'DescNullsLast'; - return [correspondingField.name, direction]; - }), + return [correspondingField.name, direction]; + }) + .filter(isDefined), ); return { diff --git a/packages/twenty-front/src/modules/object-record/query-keys/types/QueryFields.ts b/packages/twenty-front/src/modules/object-record/query-keys/types/QueryFields.ts new file mode 100644 index 000000000000..78b2e21c3bc3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/query-keys/types/QueryFields.ts @@ -0,0 +1 @@ +export type QueryFields = Record; diff --git a/packages/twenty-front/src/modules/object-record/query-keys/types/QueryKey.ts b/packages/twenty-front/src/modules/object-record/query-keys/types/QueryKey.ts new file mode 100644 index 000000000000..c1a7a598495b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/query-keys/types/QueryKey.ts @@ -0,0 +1,10 @@ +import { QueryFields } from '@/object-record/query-keys/types/QueryFields'; +import { ObjectRecordQueryVariables } from '@/object-record/types/ObjectRecordQueryVariables'; + +export type QueryKey = { + objectNameSingular: string; + variables: ObjectRecordQueryVariables; + depth?: number; + fields?: QueryFields; // Todo: Fields should be required + fieldsFactory?: (fieldsFactoryParam: any) => QueryFields; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx index a609203749cd..f8370cfb24d9 100644 --- a/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-action-bar/hooks/useRecordActionBar.tsx @@ -1,24 +1,28 @@ -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { isNonEmptyString } from '@sniptt/guards'; import { useRecoilCallback, useSetRecoilState } from 'recoil'; - -import { useFavorites } from '@/favorites/hooks/useFavorites'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; -import { useExecuteQuickActionOnOneRecord } from '@/object-record/hooks/useExecuteQuickActionOnOneRecord'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { IconClick, + IconFileExport, IconHeart, IconHeartOff, IconMail, IconPuzzle, IconTrash, -} from '@/ui/display/icon'; +} from 'twenty-ui'; + +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +import { useExecuteQuickActionOnOneRecord } from '@/object-record/hooks/useExecuteQuickActionOnOneRecord'; +import { useExportTableData } from '@/object-record/record-index/options/hooks/useExportTableData'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState'; import { ContextMenuEntry } from '@/ui/navigation/context-menu/types/ContextMenuEntry'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { isDefined } from '~/utils/isDefined'; type useRecordActionBarProps = { objectMetadataItem: ObjectMetadataItem; @@ -33,6 +37,8 @@ export const useRecordActionBar = ({ }: useRecordActionBarProps) => { const setContextMenuEntries = useSetRecoilState(contextMenuEntriesState); const setActionBarEntriesState = useSetRecoilState(actionBarEntriesState); + const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] = + useState(false); const { createFavorite, favorites, deleteFavorite } = useFavorites(); @@ -44,29 +50,40 @@ export const useRecordActionBar = ({ objectNameSingular: objectMetadataItem.nameSingular, }); - const handleFavoriteButtonClick = useRecoilCallback(({ snapshot }) => () => { - if (selectedRecordIds.length > 1) { - return; - } - - const selectedRecordId = selectedRecordIds[0]; - const selectedRecord = snapshot - .getLoadable(recordStoreFamilyState(selectedRecordId)) - .getValue(); - - const foundFavorite = favorites?.find( - (favorite) => favorite.recordId === selectedRecordId, - ); - - const isFavorite = !!selectedRecordId && !!foundFavorite; - - if (isFavorite) { - deleteFavorite(foundFavorite.id); - } else if (selectedRecord) { - createFavorite(selectedRecord, objectMetadataItem.nameSingular); - } - callback?.(); - }); + const handleFavoriteButtonClick = useRecoilCallback( + ({ snapshot }) => + () => { + if (selectedRecordIds.length > 1) { + return; + } + + const selectedRecordId = selectedRecordIds[0]; + const selectedRecord = snapshot + .getLoadable(recordStoreFamilyState(selectedRecordId)) + .getValue(); + + const foundFavorite = favorites?.find( + (favorite) => favorite.recordId === selectedRecordId, + ); + + const isFavorite = !!selectedRecordId && !!foundFavorite; + + if (isFavorite) { + deleteFavorite(foundFavorite.id); + } else if (isDefined(selectedRecord)) { + createFavorite(selectedRecord, objectMetadataItem.nameSingular); + } + callback?.(); + }, + [ + callback, + createFavorite, + deleteFavorite, + favorites, + objectMetadataItem.nameSingular, + selectedRecordIds, + ], + ); const handleDeleteClick = useCallback(async () => { callback?.(); @@ -82,16 +99,58 @@ export const useRecordActionBar = ({ ); }, [callback, executeQuickActionOnOneRecord, selectedRecordIds]); + const { progress, download } = useExportTableData({ + delayMs: 100, + filename: `${objectMetadataItem.nameSingular}.csv`, + objectNameSingular: objectMetadataItem.nameSingular, + recordIndexId: objectMetadataItem.namePlural, + }); + + const isRemoteObject = objectMetadataItem.isRemote; + const baseActions: ContextMenuEntry[] = useMemo( + () => [ + { + label: `${progress === undefined ? `Export` : `Export (${progress}%)`}`, + Icon: IconFileExport, + accent: 'default', + onClick: () => download(), + }, + ], + [download, progress], + ); + + const deletionActions: ContextMenuEntry[] = useMemo( () => [ { label: 'Delete', Icon: IconTrash, accent: 'danger', - onClick: () => handleDeleteClick(), + onClick: () => setIsDeleteRecordsModalOpen(true), + ConfirmationModal: ( + handleDeleteClick()} + deleteButtonText={`Delete ${ + selectedRecordIds.length > 1 ? 'Records' : 'Record' + }`} + /> + ), }, ], - [handleDeleteClick], + [ + handleDeleteClick, + selectedRecordIds, + isDeleteRecordsModalOpen, + setIsDeleteRecordsModalOpen, + ], ); const dataExecuteQuickActionOnmentEnabled = useIsFeatureEnabled( @@ -107,8 +166,9 @@ export const useRecordActionBar = ({ return { setContextMenuEntries: useCallback(() => { setContextMenuEntries([ + ...(isRemoteObject ? [] : deletionActions), ...baseActions, - ...(isFavorite && hasOnlyOneRecordSelected + ...(!isRemoteObject && isFavorite && hasOnlyOneRecordSelected ? [ { label: 'Remove from favorites', @@ -117,7 +177,7 @@ export const useRecordActionBar = ({ }, ] : []), - ...(!isFavorite && hasOnlyOneRecordSelected + ...(!isRemoteObject && !isFavorite && hasOnlyOneRecordSelected ? [ { label: 'Add to favorites', @@ -129,9 +189,11 @@ export const useRecordActionBar = ({ ]); }, [ baseActions, + deletionActions, handleFavoriteButtonClick, hasOnlyOneRecordSelected, isFavorite, + isRemoteObject, setContextMenuEntries, ]), @@ -156,12 +218,15 @@ export const useRecordActionBar = ({ }, ] : []), + ...(isRemoteObject ? [] : deletionActions), ...baseActions, ]); }, [ baseActions, dataExecuteQuickActionOnmentEnabled, + deletionActions, handleExecuteQuickActionOnClick, + isRemoteObject, setActionBarEntriesState, ]), }; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/action-bar/components/RecordBoardDeprecatedActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/action-bar/components/RecordBoardDeprecatedActionBar.tsx deleted file mode 100644 index b2e01c273a1e..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/action-bar/components/RecordBoardDeprecatedActionBar.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { useRecoilValue } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { ActionBar } from '@/ui/navigation/action-bar/components/ActionBar'; - -export const RecordBoardDeprecatedActionBar = () => { - const { selectedCardIdsSelector } = useRecordBoardDeprecatedScopedStates(); - const selectedCardIds = useRecoilValue(selectedCardIdsSelector); - - if (!selectedCardIds.length) { - return null; - } - - return ; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/NewButton.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/NewButton.tsx deleted file mode 100644 index 867a57063890..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/NewButton.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; - -import { IconPlus } from '@/ui/display/icon/index'; - -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}; - } -`; - -type NewButtonProps = { - onClick: () => void; -}; - -export const NewButton = ({ onClick }: NewButtonProps) => { - const theme = useTheme(); - - return ( - - - New - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx deleted file mode 100644 index 854a138297d3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecated.tsx +++ /dev/null @@ -1,162 +0,0 @@ -import { useCallback, useRef } from 'react'; -import styled from '@emotion/styled'; -import { DragDropContext, OnDragEndResponder } from '@hello-pangea/dnd'; // Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 -import { useRecoilValue } from 'recoil'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { RecordBoardDeprecatedActionBar } from '@/object-record/record-board-deprecated/action-bar/components/RecordBoardDeprecatedActionBar'; -import { RecordBoardDeprecatedInternalEffect } from '@/object-record/record-board-deprecated/components/RecordBoardDeprecatedInternalEffect'; -import { RecordBoardDeprecatedContextMenu } from '@/object-record/record-board-deprecated/context-menu/components/RecordBoardDeprecatedContextMenu'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { useSetRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { Opportunity } from '@/pipeline/types/Opportunity'; -import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; -import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; -import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; -import { logError } from '~/utils/logError'; - -import { BoardColumnDefinition } from '../types/BoardColumnDefinition'; -import { BoardOptions } from '../types/BoardOptions'; - -import { RecordBoardDeprecatedColumn } from './RecordBoardDeprecatedColumn'; - -export type RecordBoardDeprecatedProps = { - recordBoardId: string; - boardOptions: BoardOptions; - onColumnAdd?: (boardColumn: BoardColumnDefinition) => void; - onColumnDelete?: (boardColumnId: string) => void; - onEditColumnTitle: (params: { - columnId: string; - title: string; - color: string; - }) => void; -}; - -const StyledBoard = styled.div` - border-top: 1px solid ${({ theme }) => theme.border.color.light}; - display: flex; - flex: 1; - flex-direction: row; - margin-left: ${({ theme }) => theme.spacing(2)}; - margin-right: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledWrapper = styled.div` - display: flex; - flex-direction: column; - height: 100%; - overflow: hidden; - position: relative; - width: 100%; -`; - -const StyledBoardHeader = styled.div` - position: relative; - z-index: 1; -`; - -export const RecordBoardDeprecated = ({ - recordBoardId, - boardOptions, - onColumnDelete, - onEditColumnTitle, -}: RecordBoardDeprecatedProps) => { - const recordBoardScopeId = recordBoardId; - - const { boardColumnsState } = useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId, - }); - const boardColumns = useRecoilValue(boardColumnsState); - - const { updateOneRecord: updateOneOpportunity } = - useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Opportunity, - }); - - const { unselectAllActiveCards, setCardSelected } = - useSetRecordBoardDeprecatedCardSelectedInternal({ recordBoardScopeId }); - - const updatePipelineProgressStageInDB = useCallback( - async (pipelineProgressId: string, pipelineStepId: string) => { - await updateOneOpportunity?.({ - idToUpdate: pipelineProgressId, - updateOneRecordInput: { - pipelineStepId: pipelineStepId, - }, - }); - }, - [updateOneOpportunity], - ); - - useListenClickOutsideByClassName({ - classNames: ['entity-board-card'], - excludeClassNames: ['action-bar', 'context-menu'], - callback: unselectAllActiveCards, - }); - - const onDragEnd: OnDragEndResponder = useCallback( - async (result) => { - if (!boardColumns) return; - - try { - const draggedEntityId = result.draggableId; - const destinationColumnId = result.destination?.droppableId; - - if ( - draggedEntityId && - destinationColumnId && - updatePipelineProgressStageInDB - ) { - await updatePipelineProgressStageInDB( - draggedEntityId, - destinationColumnId, - ); - } - } catch (e) { - logError(e); - } - }, - [boardColumns, updatePipelineProgressStageInDB], - ); - - const sortedBoardColumns = [...boardColumns].sort((a, b) => { - return a.position - b.position; - }); - - const boardRef = useRef(null); - - return ( - - - - - - - - - - - {sortedBoardColumns.map((column) => ( - - ))} - - - - - - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedCard.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedCard.tsx deleted file mode 100644 index c4e630f783b3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedCard.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { Draggable } from '@hello-pangea/dnd'; -import { useSetRecoilState } from 'recoil'; - -import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; -import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; - -import { useCurrentRecordBoardDeprecatedCardSelectedInternal } from '../hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal'; -import { BoardOptions } from '../types/BoardOptions'; - -export const RecordBoardDeprecatedCard = ({ - recordBoardOptions, - cardId, - index, -}: { - recordBoardOptions: BoardOptions; - cardId: string; - index: number; -}) => { - const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); - const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); - - const { setCurrentCardSelected } = - useCurrentRecordBoardDeprecatedCardSelectedInternal(); - - const handleContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - setCurrentCardSelected(true); - setContextMenuPosition({ - x: event.clientX, - y: event.clientY, - }); - setContextMenuOpenState(true); - }; - - return ( - - {(draggableProvided) => ( -
- {} -
- )} -
- ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumn.tsx deleted file mode 100644 index 7566c71355c0..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumn.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import React from 'react'; -import styled from '@emotion/styled'; -import { Draggable, Droppable, DroppableProvided } from '@hello-pangea/dnd'; -import { useRecoilValue } from 'recoil'; - -import { RecordBoardDeprecatedCard } from '@/object-record/record-board-deprecated/components/RecordBoardDeprecatedCard'; -import { RecordBoardDeprecatedColumnHeader } from '@/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnHeader'; -import { BoardCardIdContext } from '@/object-record/record-board-deprecated/contexts/BoardCardIdContext'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; - -import { BoardColumnContext } from '../contexts/BoardColumnContext'; -import { recordBoardCardIdsByColumnIdFamilyState } from '../states/recordBoardCardIdsByColumnIdFamilyState'; -import { BoardOptions } from '../types/BoardOptions'; - -const StyledPlaceholder = styled.div` - min-height: 1px; -`; - -const StyledNewCardButtonContainer = styled.div` - padding-bottom: ${({ theme }) => theme.spacing(4)}; -`; - -const StyledColumnCardsContainer = styled.div` - display: flex; - flex: 1; - flex-direction: column; -`; - -const StyledColumn = styled.div<{ isFirstColumn: boolean }>` - 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)}; - position: relative; -`; - -type BoardColumnCardsContainerProps = { - children: React.ReactNode; - droppableProvided: DroppableProvided; -}; - -type RecordBoardDeprecatedColumnProps = { - recordBoardColumnId: string; - columnDefinition: BoardColumnDefinition; - recordBoardOptions: BoardOptions; - recordBoardColumnTotal: number; - onDelete?: (columnId: string) => void; - onTitleEdit: (params: { - columnId: string; - title: string; - color: string; - }) => void; -}; - -const BoardColumnCardsContainer = ({ - children, - droppableProvided, -}: BoardColumnCardsContainerProps) => { - return ( - - {children} - {droppableProvided?.placeholder} - - ); -}; - -export const RecordBoardDeprecatedColumn = ({ - recordBoardColumnId, - columnDefinition, - recordBoardOptions, - recordBoardColumnTotal, - onDelete, - onTitleEdit, -}: RecordBoardDeprecatedColumnProps) => { - const cardIds = useRecoilValue( - recordBoardCardIdsByColumnIdFamilyState(recordBoardColumnId), - ); - - const isFirstColumn = columnDefinition.position === 0; - - return ( - - onTitleEdit({ columnId: recordBoardColumnId, title, color }), - }} - > - - {(droppableProvided) => ( - - - - {cardIds.map((cardId, index) => ( - - - - ))} - - {(draggableProvided) => ( -
- - {recordBoardOptions.newCardComponent} - -
- )} -
-
-
- )} -
-
- ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx deleted file mode 100644 index 69e99b95d02b..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnDropdownMenu.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import { useCallback, useContext, useRef, useState } from 'react'; -import styled from '@emotion/styled'; -import { Key } from 'ts-key-enum'; - -import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; -import { IconArrowLeft, IconArrowRight, IconPencil } from '@/ui/display/icon'; -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 { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; - -import { BoardColumnContext } from '../contexts/BoardColumnContext'; -import { useBoardColumnsInternal } from '../hooks/internal/useRecordBoardDeprecatedColumnsInternal'; -import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope'; - -import { RecordBoardDeprecatedColumnEditTitleMenu } from './RecordBoardDeprecatedColumnEditTitleMenu'; -const StyledMenuContainer = styled.div` - position: absolute; - top: ${({ theme }) => theme.spacing(10)}; - width: 200px; - z-index: 1; -`; - -type RecordBoardDeprecatedColumnDropdownMenuProps = { - onClose: () => void; - onDelete?: (id: string) => void; - stageId: string; -}; - -type Menu = 'actions' | 'add' | 'title'; - -export const RecordBoardDeprecatedColumnDropdownMenu = ({ - onClose, - onDelete, - stageId, -}: RecordBoardDeprecatedColumnDropdownMenuProps) => { - const [currentMenu, setCurrentMenu] = useState('actions'); - const column = useContext(BoardColumnContext); - - const boardColumnMenuRef = useRef(null); - - const { handleMoveBoardColumn } = useBoardColumnsInternal(); - - const { - setHotkeyScopeAndMemorizePreviousScope, - goBackToPreviousHotkeyScope, - } = usePreviousHotkeyScope(); - - const closeMenu = useCallback(() => { - goBackToPreviousHotkeyScope(); - onClose(); - }, [goBackToPreviousHotkeyScope, onClose]); - - const setMenu = (menu: Menu) => { - if (menu === 'add') { - setHotkeyScopeAndMemorizePreviousScope( - RelationPickerHotkeyScope.RelationPicker, - ); - } - setCurrentMenu(menu); - }; - - useListenClickOutside({ - refs: [boardColumnMenuRef], - callback: closeMenu, - }); - - useScopedHotkeys( - [Key.Escape, Key.Enter], - () => { - closeMenu(); - }, - BoardColumnHotkeyScope.BoardColumn, - [], - ); - - if (!column) return <>; - - const { isFirstColumn, isLastColumn, columnDefinition } = column; - - const handleColumnMoveLeft = () => { - closeMenu(); - if (isFirstColumn) { - return; - } - handleMoveBoardColumn('left', columnDefinition); - }; - - const handleColumnMoveRight = () => { - closeMenu(); - if (isLastColumn) { - return; - } - handleMoveBoardColumn('right', columnDefinition); - }; - - return ( - - - {currentMenu === 'actions' && ( - - setMenu('title')} - LeftIcon={IconPencil} - text="Edit" - /> - - - {/* setMenu('add')} - LeftIcon={IconPlus} - text="New opportunity" - /> */} - - )} - {currentMenu === 'title' && ( - - )} - {currentMenu === 'add' && ( -
add
- // - )} -
-
- ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnEditTitleMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnEditTitleMenu.tsx deleted file mode 100644 index 4caf7d2482b0..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnEditTitleMenu.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { ChangeEvent, useCallback, useContext, useState } from 'react'; -import styled from '@emotion/styled'; -import debounce from 'lodash.debounce'; - -import { IconTrash } from '@/ui/display/icon'; -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 { MenuItemSelectColor } from '@/ui/navigation/menu-item/components/MenuItemSelectColor'; -import { - MAIN_COLOR_NAMES, - ThemeColor, -} from '@/ui/theme/constants/MainColorNames'; -import { TEXT_INPUT_STYLE } from '@/ui/theme/constants/TextInputStyle'; - -import { BoardColumnContext } from '../contexts/BoardColumnContext'; -import { useRecordBoardDeprecated } from '../hooks/useRecordBoardDeprecated'; - -const StyledEditTitleContainer = styled.div` - --vertical-padding: ${({ theme }) => theme.spacing(1)}; - - align-items: center; - - display: flex; - flex-direction: row; - height: calc(36px - 2 * var(--vertical-padding)); - padding: var(--vertical-padding) 0; - - width: calc(100%); -`; - -const StyledEditModeInput = styled.input` - ${TEXT_INPUT_STYLE} - - background: ${({ theme }) => theme.background.transparent.lighter}; - border-color: ${({ theme }) => theme.color.blue}; - border-radius: ${({ theme }) => theme.border.radius.sm}; - border-style: solid; - border-width: 1px; - box-shadow: 0px 0px 0px 3px rgba(25, 97, 237, 0.1); - font-size: ${({ theme }) => theme.font.size.sm}; - height: 100%; - width: 100%; -`; - -type RecordBoardDeprecatedColumnEditTitleMenuProps = { - onClose: () => void; - onDelete?: (id: string) => void; - title: string; - color: ThemeColor; - stageId: string; -}; - -export const RecordBoardDeprecatedColumnEditTitleMenu = ({ - onClose, - onDelete, - stageId, - title, - color, -}: RecordBoardDeprecatedColumnEditTitleMenuProps) => { - const [internalValue, setInternalValue] = useState(title); - const { onTitleEdit } = useContext(BoardColumnContext) || {}; - - const { setBoardColumns } = useRecordBoardDeprecated({ - recordBoardScopeId: 'company-board', - }); - - const debouncedOnUpdateTitle = debounce( - (newTitle) => onTitleEdit?.({ title: newTitle, color }), - 200, - ); - const handleChange = (event: ChangeEvent) => { - const title = event.target.value; - setInternalValue(title); - debouncedOnUpdateTitle(title); - - setBoardColumns((previousBoardColumns) => - previousBoardColumns.map((column) => - column.id === stageId ? { ...column, title: title } : column, - ), - ); - }; - - const handleColorChange = (newColor: ThemeColor) => { - onTitleEdit?.({ title, color: newColor }); - onClose(); - setBoardColumns((previousBoardColumns) => - previousBoardColumns.map((column) => - column.id === stageId - ? { ...column, colorCode: newColor ? newColor : 'gray' } - : column, - ), - ); - }; - - const handleDelete = useCallback(() => { - setBoardColumns((previousBoardColumns) => - previousBoardColumns.filter((column) => column.id !== stageId), - ); - onDelete?.(stageId); - onClose(); - }, [onClose, onDelete, setBoardColumns, stageId]); - - return ( - - - - - - {MAIN_COLOR_NAMES.map((colorName) => ( - handleColorChange(colorName)} - color={colorName} - selected={colorName === color} - variant="pipeline" - /> - ))} - - - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnHeader.tsx deleted file mode 100644 index 81c7bd81a60b..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedColumnHeader.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { useState } from 'react'; -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; - -import { recordBoardColumnTotalsFamilySelector } from '@/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedColumnTotalsFamilySelector'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; -import { IconDotsVertical } from '@/ui/display/icon'; -import { Tag } from '@/ui/display/tag/components/Tag'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; -import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; - -import { recordBoardCardIdsByColumnIdFamilyState } from '../states/recordBoardCardIdsByColumnIdFamilyState'; -import { BoardColumnHotkeyScope } from '../types/BoardColumnHotkeyScope'; - -import { RecordBoardDeprecatedColumnDropdownMenu } from './RecordBoardDeprecatedColumnDropdownMenu'; - -const StyledHeader = styled.div` - align-items: center; - cursor: pointer; - display: flex; - flex-direction: row; - height: 24px; - justify-content: left; - margin-bottom: ${({ theme }) => theme.spacing(2)}; - width: 100%; -`; - -const StyledAmount = styled.div` - color: ${({ theme }) => theme.font.color.tertiary}; - margin-left: ${({ theme }) => theme.spacing(2)}; -`; - -const StyledNumChildren = styled.div` - align-items: center; - background-color: ${({ theme }) => theme.background.tertiary}; - border-radius: ${({ theme }) => theme.border.radius.rounded}; - color: ${({ theme }) => theme.font.color.tertiary}; - display: flex; - height: 20px; - justify-content: center; - line-height: ${({ theme }) => theme.text.lineHeight.lg}; - margin-left: auto; - width: 16px; -`; - -const StyledHeaderActions = styled.div` - display: flex; - margin-left: auto; -`; - -type RecordBoardDeprecatedColumnHeaderProps = { - recordBoardColumnId: string; - columnDefinition: BoardColumnDefinition; - onDelete?: (columnId: string) => void; -}; - -export const RecordBoardDeprecatedColumnHeader = ({ - recordBoardColumnId, - columnDefinition, - onDelete, -}: RecordBoardDeprecatedColumnHeaderProps) => { - const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); - const [isHeaderHovered, setIsHeaderHovered] = useState(false); - - const { - setHotkeyScopeAndMemorizePreviousScope, - goBackToPreviousHotkeyScope, - } = usePreviousHotkeyScope(); - - const handleBoardColumnMenuOpen = () => { - setIsBoardColumnMenuOpen(true); - setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, { - goto: false, - }); - }; - - const handleBoardColumnMenuClose = () => { - goBackToPreviousHotkeyScope(); - setIsBoardColumnMenuOpen(false); - }; - - const boardColumnTotal = useRecoilValue( - recordBoardColumnTotalsFamilySelector(recordBoardColumnId), - ); - - const cardIds = useRecoilValue( - recordBoardCardIdsByColumnIdFamilyState(recordBoardColumnId), - ); - - return ( - <> - setIsHeaderHovered(true)} - onMouseLeave={() => setIsHeaderHovered(false)} - > - - {!!boardColumnTotal && ${boardColumnTotal}} - {!isHeaderHovered && ( - {cardIds.length} - )} - {isHeaderHovered && ( - - - {/* {}} - /> */} - - )} - - {isBoardColumnMenuOpen && ( - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedEffect.tsx deleted file mode 100644 index 1e73d9410d6a..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedEffect.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { useEffect } from 'react'; - -import { useRecordBoardDeprecated } from '@/object-record/record-board-deprecated/hooks/useRecordBoardDeprecated'; -import { BoardFieldDefinition } from '@/object-record/record-board-deprecated/types/BoardFieldDefinition'; -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; - -type RecordBoardDeprecatedEffectProps = { - recordBoardId: string; - onFieldsChange: (fields: BoardFieldDefinition[]) => void; -}; - -export const RecordBoardDeprecatedEffect = ({ - recordBoardId, - onFieldsChange, -}: RecordBoardDeprecatedEffectProps) => { - const { setOnFieldsChange } = useRecordBoardDeprecated({ - recordBoardScopeId: recordBoardId, - }); - - useEffect(() => { - setOnFieldsChange(() => onFieldsChange); - }, [onFieldsChange, setOnFieldsChange]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedInternalEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedInternalEffect.tsx deleted file mode 100644 index f9c916d32ada..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/RecordBoardDeprecatedInternalEffect.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useEffect } from 'react'; -import { useRecoilState, useRecoilValue } from 'recoil'; - -import { useObjectRecordBoardDeprecated } from '@/object-record/hooks/useObjectRecordBoardDeprecated'; -import { useRecordBoardDeprecatedActionBarEntriesInternal } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedActionBarEntriesInternal'; -import { useRecordBoardDeprecatedContextMenuEntriesInternal } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedContextMenuEntriesInternal'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { useUpdateCompanyBoardColumnsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useUpdateCompanyBoardColumnsInternal'; -import { isNonNullable } from '~/utils/isNonNullable'; - -export type RecordBoardDeprecatedInternalEffectProps = { - onFieldsChange: (fields: any) => void; -}; - -export const RecordBoardDeprecatedInternalEffect = () => { - const updateCompanyColumnsBoardInternal = - useUpdateCompanyBoardColumnsInternal(); - const { setActionBarEntries } = - useRecordBoardDeprecatedActionBarEntriesInternal(); - const { setContextMenuEntries } = - useRecordBoardDeprecatedContextMenuEntriesInternal(); - - const { - savedPipelineStepsState, - savedOpportunitiesState, - savedCompaniesState, - } = useRecordBoardDeprecatedScopedStates(); - - const { fetchMoreOpportunities, fetchMoreCompanies, opportunities } = - useObjectRecordBoardDeprecated(); - - const [savedOpportunities, setSavedOpportunities] = useRecoilState( - savedOpportunitiesState, - ); - const savedPipelineSteps = useRecoilValue(savedPipelineStepsState); - const savedCompanies = useRecoilValue(savedCompaniesState); - - useEffect(() => { - setSavedOpportunities(opportunities); - }, [opportunities, setSavedOpportunities]); - - useEffect(() => { - if (isNonNullable(fetchMoreOpportunities)) { - fetchMoreOpportunities(); - } - }, [fetchMoreOpportunities]); - - useEffect(() => { - if (isNonNullable(fetchMoreCompanies)) { - fetchMoreCompanies(); - } - }, [fetchMoreCompanies]); - - useEffect(() => { - if (savedOpportunities && savedCompanies) { - setActionBarEntries(); - setContextMenuEntries(); - - updateCompanyColumnsBoardInternal( - savedPipelineSteps, - savedOpportunities, - savedCompanies, - ); - } - }, [ - savedCompanies, - savedOpportunities, - savedPipelineSteps, - setActionBarEntries, - setContextMenuEntries, - updateCompanyColumnsBoardInternal, - ]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/__stories__/RecordBoardColumnEditTitleMenu.stories.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/__stories__/RecordBoardColumnEditTitleMenu.stories.tsx deleted file mode 100644 index 99d7262f5dfa..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/components/__stories__/RecordBoardColumnEditTitleMenu.stories.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { Meta, StoryObj } from '@storybook/react'; - -import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; - -import { RecordBoardDeprecatedColumnEditTitleMenu } from '../RecordBoardDeprecatedColumnEditTitleMenu'; - -const meta: Meta = { - title: 'UI/Layout/Board/BoardColumnMenu', - component: RecordBoardDeprecatedColumnEditTitleMenu, - decorators: [ComponentDecorator], - args: { color: 'green', title: 'Column title' }, -}; - -export default meta; -type Story = StoryObj; - -export const AllTags: Story = {}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/constants/BoardOptionsDropdownId.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/constants/BoardOptionsDropdownId.ts deleted file mode 100644 index 77727d5947f8..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/constants/BoardOptionsDropdownId.ts +++ /dev/null @@ -1 +0,0 @@ -export const BOARD_OPTIONS_DROPDOWN_ID = 'board-options-dropdown-id'; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/context-menu/components/RecordBoardDeprecatedContextMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/context-menu/components/RecordBoardDeprecatedContextMenu.tsx deleted file mode 100644 index 900d3d19952e..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/context-menu/components/RecordBoardDeprecatedContextMenu.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useRecoilValue } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { ContextMenu } from '@/ui/navigation/context-menu/components/ContextMenu'; - -export const RecordBoardDeprecatedContextMenu = () => { - const { selectedCardIdsSelector } = useRecordBoardDeprecatedScopedStates(); - const selectedCardIds = useRecoilValue(selectedCardIdsSelector); - - if (!selectedCardIds.length) { - return null; - } - return ; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/contexts/BoardCardIdContext.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/contexts/BoardCardIdContext.ts deleted file mode 100644 index b135559a75d6..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/contexts/BoardCardIdContext.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createContext } from 'react'; - -export const BoardCardIdContext = createContext(null); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/contexts/BoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/contexts/BoardColumnContext.ts deleted file mode 100644 index d1b3b57cbe59..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/contexts/BoardColumnContext.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext } from 'react'; - -import { BoardColumnDefinition } from '../types/BoardColumnDefinition'; - -type BoardColumnContextProps = { - id: string; - columnDefinition: BoardColumnDefinition; - isFirstColumn: boolean; - isLastColumn: boolean; - onTitleEdit: (params: { title: string; color: string }) => void; -}; - -export const BoardColumnContext = createContext( - null, -); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/__tests__/useRecordBoard.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/__tests__/useRecordBoard.test.tsx deleted file mode 100644 index f891ba118418..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/__tests__/useRecordBoard.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { useRecordBoardDeprecated } from '@/object-record/record-board-deprecated/hooks/useRecordBoardDeprecated'; - -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -const recordBoardScopeId = 'recordBoardScopeId'; - -const renderHookConfig = { - wrapper: Wrapper, -}; - -const useRecordBoardDeprecatedHook = () => { - const recordBoard = useRecordBoardDeprecated({ recordBoardScopeId }); - const { isBoardLoadedState, boardColumnsState, onFieldsChangeState } = - useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: recordBoardScopeId, - }); - const isBoardLoaded = useRecoilValue(isBoardLoadedState); - const boardColumns = useRecoilValue(boardColumnsState); - const onFieldsChange = useRecoilValue(onFieldsChangeState); - - return { - recordBoard, - isBoardLoaded, - boardColumns, - onFieldsChange, - }; -}; - -describe('useRecordBoardDeprecated', () => { - it('should set isBoardLoadedState', async () => { - const { result } = renderHook( - () => useRecordBoardDeprecatedHook(), - renderHookConfig, - ); - - act(() => { - result.current.recordBoard.setIsBoardLoaded(true); - }); - - await waitFor(() => { - expect(result.current.isBoardLoaded).toBe(true); - }); - }); - - it('should set boardColumnsState', async () => { - const columns = [ - { - id: '1', - title: '1', - position: 1, - }, - { - id: '1', - title: '1', - position: 1, - }, - ]; - const { result } = renderHook( - () => useRecordBoardDeprecatedHook(), - renderHookConfig, - ); - - act(() => { - result.current.recordBoard.setBoardColumns(columns); - }); - - await waitFor(() => { - expect(result.current.boardColumns).toEqual(columns); - }); - }); - - it('should set setOnFieldsChange', async () => { - const onFieldsChangeFunction = () => {}; - const onFieldsChange = jest.fn(() => onFieldsChangeFunction); - const { result } = renderHook( - () => useRecordBoardDeprecatedHook(), - renderHookConfig, - ); - - act(() => { - result.current.recordBoard.setOnFieldsChange(onFieldsChange); - }); - - await waitFor(() => { - expect(result.current.onFieldsChange).toEqual(onFieldsChangeFunction); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useCreateOpportunity.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useCreateOpportunity.test.tsx deleted file mode 100644 index 8c7e8a85c68d..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useCreateOpportunity.test.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook } from '@testing-library/react'; -import gql from 'graphql-tag'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity'; -import { recordBoardCardIdsByColumnIdFamilyState } from '@/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState'; - -const mockedUuid = 'mocked-uuid'; -jest.mock('uuid', () => ({ - v4: () => mockedUuid, -})); - -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: () => () => '\n', -})); - -const mocks = [ - { - request: { - query: gql` - mutation CreateOneOpportunity($input: OpportunityCreateInput!) { - createOpportunity(data: $input) { - id - } - } - `, - variables: { - input: { - id: mockedUuid, - pipelineStepId: 'pipelineStepId', - companyId: 'New Opportunity', - }, - }, - }, - result: jest.fn(() => ({ - data: { createOpportunity: { id: '' } }, - })), - }, -]; - -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -const renderHookConfig = { - wrapper: Wrapper, -}; - -describe('useCreateOpportunity', () => { - it('should create opportunity successfully', () => { - const companyIdname = 'New Opportunity'; - const opportunityPipelineStepId = 'pipelineStepId'; - - const { result } = renderHook( - () => ({ - createOpportunity: useCreateOpportunity(), - recordBoardCardIdsByColumnId: useRecoilValue( - recordBoardCardIdsByColumnIdFamilyState(opportunityPipelineStepId), - ), - }), - renderHookConfig, - ); - - act(() => { - result.current.createOpportunity( - companyIdname, - opportunityPipelineStepId, - ); - }); - - expect(result.current.recordBoardCardIdsByColumnId).toStrictEqual([ - mockedUuid, - ]); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useCurrentRecordBoardCardSelectedInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useCurrentRecordBoardCardSelectedInternal.test.tsx deleted file mode 100644 index b638147660e1..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useCurrentRecordBoardCardSelectedInternal.test.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { BoardCardIdContext } from '@/object-record/record-board-deprecated/contexts/BoardCardIdContext'; -import { useCurrentRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState'; - -const scopeId = 'scopeId'; -const boardCardId = 'boardCardId'; - -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe('useCurrentRecordBoardDeprecatedCardSelectedInternal', () => { - it('should update the data when selecting and deselecting the cardId', () => { - const { result } = renderHook( - () => ({ - currentCardSelect: - useCurrentRecordBoardDeprecatedCardSelectedInternal(), - activeCardIdsState: useRecoilValue( - useRecordBoardDeprecatedScopedStates().activeCardIdsState, - ), - actionBarOpenState: useRecoilValue(actionBarOpenState), - }), - { - wrapper: Wrapper, - }, - ); - - expect(result.current.activeCardIdsState).toStrictEqual([]); - expect(result.current.actionBarOpenState).toBe(false); - expect(result.current.currentCardSelect.isCurrentCardSelected).toBe(false); - - act(() => { - result.current.currentCardSelect.setCurrentCardSelected(true); - }); - - expect(result.current.activeCardIdsState).toStrictEqual([boardCardId]); - expect(result.current.actionBarOpenState).toBe(true); - expect(result.current.currentCardSelect.isCurrentCardSelected).toBe(true); - - act(() => { - result.current.currentCardSelect.setCurrentCardSelected(false); - }); - - expect(result.current.activeCardIdsState).toStrictEqual([]); - expect(result.current.actionBarOpenState).toBe(false); - expect(result.current.currentCardSelect.isCurrentCardSelected).toBe(false); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useDeleteSelectedRecordBoardCardsInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useDeleteSelectedRecordBoardCardsInternal.test.tsx deleted file mode 100644 index cfaf7edc41d8..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useDeleteSelectedRecordBoardCardsInternal.test.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import gql from 'graphql-tag'; -import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'; - -import { BoardCardIdContext } from '@/object-record/record-board-deprecated/contexts/BoardCardIdContext'; -import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity'; -import { useCurrentRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal'; -import { useDeleteSelectedRecordBoardDeprecatedCardsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { recordBoardCardIdsByColumnIdFamilyState } from '@/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState'; - -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: jest.fn().mockReturnValue(() => '\n'), -})); - -const mockedUuid = 'mocked-uuid'; -jest.mock('uuid', () => ({ v4: () => mockedUuid })); - -const mocks = [ - { - request: { - query: gql` - mutation DeleteManyOpportunities($filter: OpportunityFilterInput!) { - deleteOpportunities(filter: $filter) { - id - } - } - `, - variables: { filter: { id: { in: [mockedUuid] } } }, - }, - result: jest.fn(() => ({ - data: { deleteOpportunities: { id: '' } }, - })), - }, - { - request: { - query: gql` - mutation CreateOneOpportunity($input: OpportunityCreateInput!) { - createOpportunity(data: $input) { - id - } - } - `, - variables: { - input: { - id: mockedUuid, - pipelineStepId: 'pipelineStepId', - companyId: 'New Opportunity', - }, - }, - }, - result: jest.fn(() => ({ - data: { createOpportunity: { id: '' } }, - })), - }, -]; - -const scopeId = 'scopeId'; - -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - - - {children} - - - -); - -describe('useDeleteSelectedRecordBoardDeprecatedCardsInternal', () => { - it('should run apollo mutation and update recoil state when delete selected cards', async () => { - const companyIdname = 'New Opportunity'; - const opportunityPipelineStepId = 'pipelineStepId'; - - const { result } = renderHook( - () => ({ - createOpportunity: useCreateOpportunity(), - deleteSelectedCards: - useDeleteSelectedRecordBoardDeprecatedCardsInternal(), - setBoardColumns: useSetRecoilState( - useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: scopeId, - }).boardColumnsState, - ), - recordBoardCardIdsByColumnId: useRecoilValue( - recordBoardCardIdsByColumnIdFamilyState(opportunityPipelineStepId), - ), - currentSelect: useCurrentRecordBoardDeprecatedCardSelectedInternal(), - }), - { - wrapper: Wrapper, - }, - ); - - act(() => { - result.current.currentSelect.setCurrentCardSelected(true); - result.current.setBoardColumns([ - { - id: opportunityPipelineStepId, - title: '1', - position: 1, - }, - ]); - result.current.createOpportunity( - companyIdname, - opportunityPipelineStepId, - ); - }); - - expect(result.current.recordBoardCardIdsByColumnId).toStrictEqual([ - mockedUuid, - ]); - await act(async () => { - await result.current.deleteSelectedCards(); - }); - - await waitFor(() => { - expect(result.current.recordBoardCardIdsByColumnId).toStrictEqual([]); - expect(mocks[0].result).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardActionBarEntriesInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardActionBarEntriesInternal.test.tsx deleted file mode 100644 index 8977a5caa30c..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardActionBarEntriesInternal.test.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { useDeleteSelectedRecordBoardDeprecatedCardsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal'; -import { useRecordBoardDeprecatedActionBarEntriesInternal } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedActionBarEntriesInternal'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { IconTrash } from '@/ui/display/icon'; -import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; - -const scopeId = 'scopeId'; -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -const renderHookConfig = { - wrapper: Wrapper, -}; - -describe('useRecordBoardDeprecatedActionBarEntriesInternal', () => { - it('should update actionBarEntries', async () => { - const { result } = renderHook(() => { - const deleteSelectedBoardCards = - useDeleteSelectedRecordBoardDeprecatedCardsInternal(); - const newActionBarEntry = { - label: 'Delete', - Icon: IconTrash, - accent: 'danger', - onClick: deleteSelectedBoardCards, - }; - return { - setActionBarEntries: useRecordBoardDeprecatedActionBarEntriesInternal(), - actionBarEntries: useRecoilValue(actionBarEntriesState), - newActionBarEntry, - }; - }, renderHookConfig); - - expect(result.current.actionBarEntries).toStrictEqual([]); - - act(() => { - result.current.setActionBarEntries.setActionBarEntries(); - }); - - await waitFor(() => { - expect(JSON.stringify(result.current.actionBarEntries)).toBe( - JSON.stringify([result.current.newActionBarEntry]), - ); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardCardFieldsInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardCardFieldsInternal.test.tsx deleted file mode 100644 index 175b5bc7408b..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardCardFieldsInternal.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { act } from 'react-dom/test-utils'; -import { renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot, useRecoilState, useRecoilValue } from 'recoil'; - -import { useRecordBoardDeprecatedCardFieldsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedCardFieldsInternal'; -import { onFieldsChangeScopedState } from '@/object-record/record-board-deprecated/states/onFieldsChangeScopedState'; -import { recordBoardCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedCardFieldsScopedState'; -import { savedRecordBoardDeprecatedCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedCardFieldsScopedState'; -import { FieldType } from '@/object-record/record-field/types/FieldType'; - -const recordBoardScopeId = 'recordBoardScopeId'; - -const renderHookConfig = { - wrapper: RecoilRoot, -}; - -describe('useRecordBoardDeprecatedCardFieldsInternal', () => { - it('should toggle field visibility', async () => { - const { result } = renderHook(() => { - const [cardFieldsList, setCardFieldsList] = useRecoilState( - recordBoardCardFieldsScopedState({ scopeId: recordBoardScopeId }), - ); - return { - boardCardFields: useRecordBoardDeprecatedCardFieldsInternal({ - recordBoardScopeId, - }), - cardFieldsList, - setCardFieldsList, - }; - }, renderHookConfig); - - const field = { - position: 0, - fieldMetadataId: 'id', - label: 'label', - iconName: 'icon', - type: 'TEXT' as FieldType, - metadata: { - fieldName: 'fieldName', - }, - isVisible: true, - }; - - act(() => { - result.current.setCardFieldsList([field]); - }); - - expect(result.current.cardFieldsList[0].isVisible).toBe(true); - - act(() => { - result.current.boardCardFields.handleFieldVisibilityChange({ - ...field, - isVisible: true, - }); - }); - - waitFor(() => { - expect(result.current.cardFieldsList[0].isVisible).toBe(false); - }); - - act(() => { - result.current.boardCardFields.handleFieldVisibilityChange({ - ...field, - isVisible: false, - }); - }); - - waitFor(() => { - expect(result.current.cardFieldsList[0].isVisible).toBe(true); - }); - }); - - it('should call the onFieldsChange callback and update board card states', async () => { - const { result } = renderHook(() => { - const [onFieldsChange, setOnFieldsChange] = useRecoilState( - onFieldsChangeScopedState({ scopeId: recordBoardScopeId }), - ); - return { - boardCardFieldsHook: useRecordBoardDeprecatedCardFieldsInternal({ - recordBoardScopeId, - }), - boardCardFieldsList: useRecoilValue( - recordBoardCardFieldsScopedState({ scopeId: recordBoardScopeId }), - ), - savedBoardCardFieldsList: useRecoilValue( - savedRecordBoardDeprecatedCardFieldsScopedState({ - scopeId: recordBoardScopeId, - }), - ), - onFieldsChange, - setOnFieldsChange, - }; - }, renderHookConfig); - - const field = { - position: 0, - fieldMetadataId: 'id', - label: 'label', - iconName: 'icon', - type: 'TEXT' as FieldType, - metadata: { - fieldName: 'fieldName', - }, - isVisible: true, - }; - const onChangeFunction = jest.fn(); - - await act(async () => { - result.current.setOnFieldsChange(() => onChangeFunction); - result.current.boardCardFieldsHook.handleFieldsReorder([field]); - }); - - expect(onChangeFunction).toHaveBeenCalledWith([field]); - expect(result.current.savedBoardCardFieldsList).toStrictEqual([field]); - expect(result.current.boardCardFieldsList).toStrictEqual([field]); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardColumnsInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardColumnsInternal.test.tsx deleted file mode 100644 index 805eb178c01a..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardColumnsInternal.test.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import gql from 'graphql-tag'; -import { RecoilRoot, useRecoilState, useSetRecoilState } from 'recoil'; - -import { useBoardColumnsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedColumnsInternal'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; - -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: jest.fn().mockReturnValue(() => '\n'), -})); - -const mocks = [ - { - request: { - query: gql` - mutation UpdateOnePipelineStep( - $idToUpdate: ID! - $input: PipelineStepUpdateInput! - ) { - updatePipelineStep(id: $idToUpdate, data: $input) { - id - } - } - `, - variables: { idToUpdate: '1', input: { position: 0 } }, - }, - result: jest.fn(() => ({ - data: { updatePipelineStep: { id: '' } }, - })), - }, -]; - -const scopeId = 'scopeId'; -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -const renderHookConfig = { - wrapper: Wrapper, -}; - -describe('useBoardColumnsInternal', () => { - it('should update boardColumns state when moving to left and right', async () => { - const { result } = renderHook(() => { - const [boardColumnsList, setBoardColumnsList] = useRecoilState( - useRecordBoardDeprecatedScopedStates().boardColumnsState, - ); - return { - boardColumns: useBoardColumnsInternal(), - boardColumnsList, - setBoardColumnsList, - }; - }, renderHookConfig); - const columns: BoardColumnDefinition[] = [ - { - id: '1', - title: '1', - position: 0, - }, - { - id: '2', - title: '2', - position: 1, - }, - ]; - act(() => { - result.current.setBoardColumnsList(columns); - }); - - act(() => { - result.current.boardColumns.handleMoveBoardColumn('right', columns[0]); - }); - - await waitFor(() => { - expect(result.current.boardColumnsList).toStrictEqual([ - { id: '2', title: '2', position: 0, index: 0 }, - { id: '1', title: '1', position: 1, index: 1 }, - ]); - }); - - act(() => { - result.current.boardColumns.handleMoveBoardColumn('left', columns[0]); - }); - - await waitFor(() => { - expect(result.current.boardColumnsList).toStrictEqual([ - { id: '1', title: '1', position: 0, index: 0 }, - { id: '2', title: '2', position: 1, index: 1 }, - ]); - }); - }); - - it('should call apollo mutation after persistBoardColumns', async () => { - const { result } = renderHook(() => { - return { - boardColumns: useBoardColumnsInternal(), - setBoardColumnsList: useSetRecoilState( - useRecordBoardDeprecatedScopedStates().boardColumnsState, - ), - }; - }, renderHookConfig); - const columns: BoardColumnDefinition[] = [ - { - id: '1', - title: '1', - position: 0, - }, - ]; - act(() => { - result.current.setBoardColumnsList(columns); - }); - - act(() => { - result.current.boardColumns.persistBoardColumns(); - }); - - await waitFor(() => { - expect(mocks[0].result).toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardContextMenuEntriesInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardContextMenuEntriesInternal.test.tsx deleted file mode 100644 index a5b7b02aafbb..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useRecordBoardContextMenuEntriesInternal.test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { MockedProvider } from '@apollo/client/testing'; -import { act, renderHook, waitFor } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { useDeleteSelectedRecordBoardDeprecatedCardsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal'; -import { useRecordBoardDeprecatedContextMenuEntriesInternal } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedContextMenuEntriesInternal'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { IconTrash } from '@/ui/display/icon'; -import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState'; - -const scopeId = 'scopeId'; -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - - {children} - - -); - -describe('useRecordBoardDeprecatedContextMenuEntriesInternal', () => { - it('should update contextEntries', async () => { - const { result } = renderHook( - () => { - const deleteSelectedBoardCards = - useDeleteSelectedRecordBoardDeprecatedCardsInternal(); - const newContextEntry = { - label: 'Delete', - Icon: IconTrash, - accent: 'danger', - onClick: deleteSelectedBoardCards, - }; - return { - setContextEntries: - useRecordBoardDeprecatedContextMenuEntriesInternal(), - contextEntries: useRecoilValue(contextMenuEntriesState), - newContextEntry, - }; - }, - { - wrapper: Wrapper, - }, - ); - - expect(result.current.contextEntries).toStrictEqual([]); - - act(() => { - result.current.setContextEntries.setContextMenuEntries(); - }); - - await waitFor(() => { - expect(JSON.stringify(result.current.contextEntries)).toBe( - JSON.stringify([result.current.newContextEntry]), - ); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useSetRecordBoardCardSelectedInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useSetRecordBoardCardSelectedInternal.test.tsx deleted file mode 100644 index 849567126128..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useSetRecordBoardCardSelectedInternal.test.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { useSetRecordBoardDeprecatedCardSelectedInternal } from '@/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { isRecordBoardDeprecatedCardSelectedFamilyState } from '@/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardSelectedFamilyState'; - -const scopeId = 'scopeId'; -const boardCardId = 'boardCardId'; - -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -const recordBoardScopeId = 'recordBoardScopeId'; - -describe('useSetRecordBoardDeprecatedCardSelectedInternal', () => { - it('should update the data when selecting and deselecting the cardId', async () => { - const { result } = renderHook( - () => { - return { - cardSelect: useSetRecordBoardDeprecatedCardSelectedInternal({ - recordBoardScopeId, - }), - isSelected: useRecoilValue( - isRecordBoardDeprecatedCardSelectedFamilyState(boardCardId), - ), - }; - }, - { - wrapper: Wrapper, - }, - ); - - expect(result.current.isSelected).toBe(false); - - act(() => { - result.current.cardSelect.setCardSelected(boardCardId, true); - }); - - expect(result.current.isSelected).toBe(true); - - act(() => { - result.current.cardSelect.setCardSelected(boardCardId, false); - }); - - expect(result.current.isSelected).toBe(false); - - act(() => { - result.current.cardSelect.setCardSelected(boardCardId, true); - result.current.cardSelect.unselectAllActiveCards(); - }); - - expect(result.current.isSelected).toBe(false); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useUpdateCompanyBoardColumnsInternal.test.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useUpdateCompanyBoardColumnsInternal.test.tsx deleted file mode 100644 index eadecd26865f..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/__tests__/useUpdateCompanyBoardColumnsInternal.test.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; - -import { CompanyForBoard } from '@/companies/types/CompanyProgress'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { useUpdateCompanyBoardColumnsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useUpdateCompanyBoardColumnsInternal'; -import { RecordBoardDeprecatedScope } from '@/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope'; -import { recordBoardCardIdsByColumnIdFamilyState } from '@/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState'; -import { currentPipelineStepsState } from '@/pipeline/states/currentPipelineStepsState'; -import { Opportunity } from '@/pipeline/types/Opportunity'; -import { PipelineStep } from '@/pipeline/types/PipelineStep'; - -const scopeId = 'scopeId'; -const Wrapper = ({ children }: { children: React.ReactNode }) => ( - - {children} - -); - -describe('useUpdateCompanyBoardColumnsInternal', () => { - it('should update recoil state after updateCompanyBoardColumns call ', async () => { - const { result } = renderHook( - () => { - return { - updateCompanyBoardColumns: useUpdateCompanyBoardColumnsInternal(), - currentPipeline: useRecoilValue(currentPipelineStepsState), - boardColumns: useRecoilValue( - useRecordBoardDeprecatedScopedStates().boardColumnsState, - ), - savedBoardColumns: useRecoilValue( - useRecordBoardDeprecatedScopedStates().savedBoardColumnsState, - ), - idsByColumnId: useRecoilValue( - recordBoardCardIdsByColumnIdFamilyState('1'), - ), - }; - }, - { - wrapper: Wrapper, - }, - ); - - const pipelineSteps: PipelineStep[] = [ - { - id: '1', - name: 'Step 1', - color: 'red', - position: 1, - createdAt: '2024-01-12', - updatedAt: '2024-01-12', - }, - { - id: '2', - name: 'Step 2', - color: 'blue', - position: 1, - createdAt: '2024-01-12', - updatedAt: '2024-01-12', - }, - ]; - const opportunity: Opportunity = { - id: '123', - amount: { - amountMicros: 1000000, - currencyCode: 'USD', - }, - closeDate: new Date('2024-02-01'), - probability: 0.75, - pipelineStepId: '1', - pipelineStep: pipelineSteps[0], - pointOfContactId: '456', - pointOfContact: { - id: '456', - name: { - firstName: 'John', - lastName: 'Doe', - }, - avatarUrl: 'https://example.com/avatar.jpg', - }, - }; - - const companyForBoard: CompanyForBoard = { - id: '789', - name: 'Acme Inc.', - domainName: 'acme.com', - }; - - expect(result.current.currentPipeline).toStrictEqual([]); - expect(result.current.savedBoardColumns).toStrictEqual([]); - expect(result.current.boardColumns).toStrictEqual([]); - expect(result.current.idsByColumnId).toStrictEqual([]); - - act(() => { - result.current.updateCompanyBoardColumns( - pipelineSteps, - [opportunity], - [companyForBoard], - ); - }); - - const expectedBoardColumns = [ - { id: '1', title: 'Step 1', colorCode: 'red', position: 1 }, - { id: '2', title: 'Step 2', colorCode: 'blue', position: 1 }, - ]; - - expect(result.current.currentPipeline).toStrictEqual(pipelineSteps); - expect(result.current.savedBoardColumns).toStrictEqual( - expectedBoardColumns, - ); - expect(result.current.boardColumns).toStrictEqual(expectedBoardColumns); - expect(result.current.idsByColumnId).toStrictEqual([opportunity.id]); - }); -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity.ts deleted file mode 100644 index fe241054fc2e..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useRecoilCallback } from 'recoil'; -import { v4 } from 'uuid'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { recordBoardCardIdsByColumnIdFamilyState } from '@/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState'; -import { Opportunity } from '@/pipeline/types/Opportunity'; - -export const useCreateOpportunity = () => { - const { createOneRecord: createOneOpportunity } = - useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.Opportunity, - }); - - const createOpportunity = useRecoilCallback( - ({ set }) => - async (companyId: string, pipelineStepId: string) => { - const newUuid = v4(); - - set( - recordBoardCardIdsByColumnIdFamilyState(pipelineStepId), - (oldValue) => [...oldValue, newUuid], - ); - - await createOneOpportunity?.({ - id: newUuid, - name: 'Opportunity', - pipelineStepId, - companyId: companyId, - }); - }, - [createOneOpportunity], - ); - - return createOpportunity; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal.ts deleted file mode 100644 index 29f333e1cb98..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useCurrentRecordBoardDeprecatedCardSelectedInternal.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useContext } from 'react'; -import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState'; - -import { BoardCardIdContext } from '../../contexts/BoardCardIdContext'; -import { isRecordBoardDeprecatedCardSelectedFamilyState } from '../../states/isRecordBoardDeprecatedCardSelectedFamilyState'; - -export const useCurrentRecordBoardDeprecatedCardSelectedInternal = () => { - const currentCardId = useContext(BoardCardIdContext); - - const isCurrentCardSelected = useRecoilValue( - isRecordBoardDeprecatedCardSelectedFamilyState(currentCardId ?? ''), - ); - - const { activeCardIdsState } = useRecordBoardDeprecatedScopedStates(); - - const setActiveCardIds = useSetRecoilState(activeCardIdsState); - - const setCurrentCardSelected = useRecoilCallback( - ({ set }) => - (selected: boolean) => { - if (!currentCardId) return; - - set( - isRecordBoardDeprecatedCardSelectedFamilyState(currentCardId), - selected, - ); - set(actionBarOpenState, selected); - - if (selected) { - setActiveCardIds((prevActiveCardIds) => [ - ...prevActiveCardIds, - currentCardId, - ]); - } else { - setActiveCardIds((prevActiveCardIds) => - prevActiveCardIds.filter((id) => id !== currentCardId), - ); - } - }, - [currentCardId, setActiveCardIds], - ); - - return { - isCurrentCardSelected, - setCurrentCardSelected, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal.ts deleted file mode 100644 index eae6d8196278..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useApolloClient } from '@apollo/client'; -import { useRecoilCallback } from 'recoil'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; - -import { useRemoveRecordBoardDeprecatedCardIdsInternal } from './useRemoveRecordBoardDeprecatedCardIdsInternal'; - -export const useDeleteSelectedRecordBoardDeprecatedCardsInternal = () => { - const removeCardIds = useRemoveRecordBoardDeprecatedCardIdsInternal(); - const apolloClient = useApolloClient(); - - const { deleteManyRecords: deleteManyOpportunities } = useDeleteManyRecords({ - objectNameSingular: CoreObjectNameSingular.Opportunity, - }); - - const { selectedCardIdsSelector } = useRecordBoardDeprecatedScopedStates(); - - const deleteSelectedBoardCards = useRecoilCallback( - ({ snapshot }) => - async () => { - const selectedCardIds = snapshot - .getLoadable(selectedCardIdsSelector) - .getValue(); - - await deleteManyOpportunities?.(selectedCardIds); - removeCardIds(selectedCardIds); - selectedCardIds.forEach((id) => { - apolloClient.cache.evict({ id: `Opportunity:${id}` }); - }); - }, - [ - selectedCardIdsSelector, - removeCardIds, - deleteManyOpportunities, - apolloClient.cache, - ], - ); - - return deleteSelectedBoardCards; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedActionBarEntriesInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedActionBarEntriesInternal.ts deleted file mode 100644 index 12f9a0e18672..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedActionBarEntriesInternal.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; - -import { useDeleteSelectedRecordBoardDeprecatedCardsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal'; -import { IconTrash } from '@/ui/display/icon'; -import { actionBarEntriesState } from '@/ui/navigation/action-bar/states/actionBarEntriesState'; - -export const useRecordBoardDeprecatedActionBarEntriesInternal = () => { - const setActionBarEntriesRecoil = useSetRecoilState(actionBarEntriesState); - - const deleteSelectedBoardCards = - useDeleteSelectedRecordBoardDeprecatedCardsInternal(); - - const setActionBarEntries = useCallback(() => { - setActionBarEntriesRecoil([ - { - label: 'Delete', - Icon: IconTrash, - accent: 'danger', - onClick: deleteSelectedBoardCards, - }, - ]); - }, [deleteSelectedBoardCards, setActionBarEntriesRecoil]); - - return { - setActionBarEntries, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedCardFieldsInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedCardFieldsInternal.ts deleted file mode 100644 index 6ee2c04f4d50..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedCardFieldsInternal.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { useCallback } from 'react'; -import { useRecoilCallback, useSetRecoilState } from 'recoil'; - -import { RecordBoardDeprecatedScopeInternalContext } from '@/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext'; -import { onFieldsChangeScopedState } from '@/object-record/record-board-deprecated/states/onFieldsChangeScopedState'; -import { recordBoardCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedCardFieldsScopedState'; -import { savedRecordBoardDeprecatedCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedCardFieldsScopedState'; -import { BoardFieldDefinition } from '@/object-record/record-board-deprecated/types/BoardFieldDefinition'; -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; -import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; - -type useRecordBoardDeprecatedCardFieldsInternalProps = { - recordBoardScopeId?: string; -}; - -export const useRecordBoardDeprecatedCardFieldsInternal = ( - props?: useRecordBoardDeprecatedCardFieldsInternalProps, -) => { - const scopeId = useAvailableScopeIdOrThrow( - RecordBoardDeprecatedScopeInternalContext, - props?.recordBoardScopeId, - ); - - const setBoardCardFields = useSetRecoilState( - recordBoardCardFieldsScopedState({ scopeId }), - ); - - const setSavedBoardCardFields = useSetRecoilState( - savedRecordBoardDeprecatedCardFieldsScopedState({ scopeId }), - ); - - const handleFieldVisibilityChange = useRecoilCallback( - ({ snapshot }) => - async ( - field: Omit, 'size' | 'position'>, - ) => { - const existingFields = await snapshot - .getLoadable(recordBoardCardFieldsScopedState({ scopeId })) - .getValue(); - - const fieldIndex = existingFields.findIndex( - ({ fieldMetadataId }) => field.fieldMetadataId === fieldMetadataId, - ); - const fields = [...existingFields]; - - if (fieldIndex === -1) { - fields.push({ ...field, position: existingFields.length }); - } else { - fields[fieldIndex] = { - ...field, - isVisible: !field.isVisible, - position: existingFields.length, - }; - } - - setSavedBoardCardFields(fields); - setBoardCardFields(fields); - - const onFieldsChange = snapshot - .getLoadable(onFieldsChangeScopedState({ scopeId })) - .getValue(); - - onFieldsChange?.(fields); - }, - [scopeId, setBoardCardFields, setSavedBoardCardFields], - ); - - const handleFieldsChange = useRecoilCallback( - ({ snapshot }) => - async (fields: BoardFieldDefinition[]) => { - setSavedBoardCardFields(fields); - setBoardCardFields(fields); - - const onFieldsChange = snapshot - .getLoadable(onFieldsChangeScopedState({ scopeId })) - .getValue(); - - await onFieldsChange?.(fields); - }, - [scopeId, setBoardCardFields, setSavedBoardCardFields], - ); - - const handleFieldsReorder = useCallback( - async (fields: BoardFieldDefinition[]) => { - const updatedFields = fields.map((column, index) => ({ - ...column, - position: index, - })); - - await handleFieldsChange(updatedFields); - }, - [handleFieldsChange], - ); - - return { handleFieldVisibilityChange, handleFieldsReorder }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedColumnsInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedColumnsInternal.ts deleted file mode 100644 index 1c9e98c753d5..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedColumnsInternal.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useRecoilState } from 'recoil'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { PipelineStep } from '@/pipeline/types/PipelineStep'; -import { useMoveViewColumns } from '@/views/hooks/useMoveViewColumns'; - -import { BoardColumnDefinition } from '../../types/BoardColumnDefinition'; - -export const useBoardColumnsInternal = () => { - const { boardColumnsState } = useRecordBoardDeprecatedScopedStates(); - const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState); - - const { handleColumnMove } = useMoveViewColumns(); - - const { updateOneRecord: updateOnePipelineStep } = - useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.PipelineStep, - }); - - const updatedPipelineSteps = (stages: BoardColumnDefinition[]) => { - if (!stages.length) return; - - return Promise.all( - stages.map( - (stage) => - updateOnePipelineStep?.({ - idToUpdate: stage.id, - updateOneRecordInput: { - position: stage.position, - }, - }), - ), - ); - }; - - const persistBoardColumns = async () => { - await updatedPipelineSteps(boardColumns); - }; - - const handleMoveBoardColumn = ( - direction: 'left' | 'right', - column: BoardColumnDefinition, - ) => { - const currentColumnArrayIndex = boardColumns.findIndex( - (tableColumn) => tableColumn.id === column.id, - ); - const columns = handleColumnMove( - direction, - currentColumnArrayIndex, - boardColumns, - ); - setBoardColumns(columns); - }; - - return { handleMoveBoardColumn, persistBoardColumns }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedContextMenuEntriesInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedContextMenuEntriesInternal.ts deleted file mode 100644 index 4e3d7730c185..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedContextMenuEntriesInternal.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useCallback } from 'react'; -import { useSetRecoilState } from 'recoil'; - -import { useDeleteSelectedRecordBoardDeprecatedCardsInternal } from '@/object-record/record-board-deprecated/hooks/internal/useDeleteSelectedRecordBoardDeprecatedCardsInternal'; -import { IconTrash } from '@/ui/display/icon'; -import { contextMenuEntriesState } from '@/ui/navigation/context-menu/states/contextMenuEntriesState'; - -export const useRecordBoardDeprecatedContextMenuEntriesInternal = () => { - const setContextMenuEntriesRecoil = useSetRecoilState( - contextMenuEntriesState, - ); - - const deleteSelectedBoardCards = - useDeleteSelectedRecordBoardDeprecatedCardsInternal(); - - const setContextMenuEntries = useCallback(() => { - setContextMenuEntriesRecoil([ - { - label: 'Delete', - Icon: IconTrash, - accent: 'danger', - onClick: deleteSelectedBoardCards, - }, - ]); - }, [deleteSelectedBoardCards, setContextMenuEntriesRecoil]); - - return { - setContextMenuEntries, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates.ts deleted file mode 100644 index 0a5256a28219..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { RecordBoardDeprecatedScopeInternalContext } from '@/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext'; -import { getRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/utils/getRecordBoardDeprecatedScopedStates'; -import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; - -type useRecordBoardDeprecatedScopedStatesProps = { - recordBoardScopeId?: string; -}; - -export const useRecordBoardDeprecatedScopedStates = ( - args?: useRecordBoardDeprecatedScopedStatesProps, -) => { - const { recordBoardScopeId } = args ?? {}; - - const scopeId = useAvailableScopeIdOrThrow( - RecordBoardDeprecatedScopeInternalContext, - recordBoardScopeId, - ); - - const { - activeCardIdsState, - availableBoardCardFieldsState, - boardColumnsState, - isBoardLoadedState, - isCompactViewEnabledState, - savedBoardColumnsState, - boardFiltersState, - boardSortsState, - onFieldsChangeState, - boardCardFieldsByKeySelector, - hiddenBoardCardFieldsSelector, - selectedCardIdsSelector, - visibleBoardCardFieldsSelector, - savedCompaniesState, - savedOpportunitiesState, - savedPipelineStepsState, - } = getRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: scopeId, - }); - - return { - scopeId, - activeCardIdsState, - availableBoardCardFieldsState, - boardColumnsState, - isBoardLoadedState, - isCompactViewEnabledState, - savedBoardColumnsState, - boardFiltersState, - boardSortsState, - onFieldsChangeState, - boardCardFieldsByKeySelector, - hiddenBoardCardFieldsSelector, - selectedCardIdsSelector, - visibleBoardCardFieldsSelector, - savedCompaniesState, - savedOpportunitiesState, - savedPipelineStepsState, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRemoveRecordBoardDeprecatedCardIdsInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRemoveRecordBoardDeprecatedCardIdsInternal.ts deleted file mode 100644 index ce9a71e24a77..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useRemoveRecordBoardDeprecatedCardIdsInternal.ts +++ /dev/null @@ -1,32 +0,0 @@ -// Atlassian dnd does not support StrictMode from RN 18, so we use a fork @hello-pangea/dnd https://github.com/atlassian/react-beautiful-dnd/issues/2350 -import { useRecoilCallback } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; - -import { recordBoardCardIdsByColumnIdFamilyState } from '../../states/recordBoardCardIdsByColumnIdFamilyState'; - -export const useRemoveRecordBoardDeprecatedCardIdsInternal = () => { - const { boardColumnsState } = useRecordBoardDeprecatedScopedStates(); - - return useRecoilCallback( - ({ snapshot, set }) => - (cardIdToRemove: string[]) => { - const boardColumns = snapshot - .getLoadable(boardColumnsState) - .valueOrThrow(); - - boardColumns.forEach((boardColumn) => { - const columnCardIds = snapshot - .getLoadable( - recordBoardCardIdsByColumnIdFamilyState(boardColumn.id), - ) - .valueOrThrow(); - set( - recordBoardCardIdsByColumnIdFamilyState(boardColumn.id), - columnCardIds.filter((cardId) => !cardIdToRemove.includes(cardId)), - ); - }); - }, - [boardColumnsState], - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal.ts deleted file mode 100644 index 93cee6d284c0..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useSetRecordBoardDeprecatedCardSelectedInternal.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { RecordBoardDeprecatedScopeInternalContext } from '@/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext'; -import { actionBarOpenState } from '@/ui/navigation/action-bar/states/actionBarIsOpenState'; -import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; - -import { isRecordBoardDeprecatedCardSelectedFamilyState } from '../../states/isRecordBoardDeprecatedCardSelectedFamilyState'; - -export const useSetRecordBoardDeprecatedCardSelectedInternal = (props: any) => { - const scopeId = useAvailableScopeIdOrThrow( - RecordBoardDeprecatedScopeInternalContext, - props?.recordBoardScopeId, - ); - const { activeCardIdsState } = useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: scopeId, - }); - - const setCardSelected = useRecoilCallback( - ({ set, snapshot }) => - (cardId: string, selected: boolean) => { - const activeCardIds = snapshot.getLoadable(activeCardIdsState).contents; - - set(isRecordBoardDeprecatedCardSelectedFamilyState(cardId), selected); - set(actionBarOpenState, selected || activeCardIds.length > 0); - - if (selected) { - set(activeCardIdsState, [...activeCardIds, cardId]); - } else { - set( - activeCardIdsState, - activeCardIds.filter((id: string) => id !== cardId), - ); - } - }, - [activeCardIdsState], - ); - - const unselectAllActiveCards = useRecoilCallback( - ({ set, snapshot }) => - () => { - const activeCardIds = snapshot.getLoadable(activeCardIdsState).contents; - - activeCardIds.forEach((cardId: string) => { - set(isRecordBoardDeprecatedCardSelectedFamilyState(cardId), false); - }); - - set(activeCardIdsState, []); - set(actionBarOpenState, false); - }, - [activeCardIdsState], - ); - - return { - setCardSelected, - unselectAllActiveCards, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useUpdateCompanyBoardColumnsInternal.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useUpdateCompanyBoardColumnsInternal.ts deleted file mode 100644 index 162bb412371a..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/internal/useUpdateCompanyBoardColumnsInternal.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { recordBoardCardIdsByColumnIdFamilyState } from '@/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { currentPipelineStepsState } from '@/pipeline/states/currentPipelineStepsState'; -import { Opportunity } from '@/pipeline/types/Opportunity'; -import { PipelineStep } from '@/pipeline/types/PipelineStep'; -import { themeColorSchema } from '@/ui/theme/utils/themeColorSchema'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; -import { logError } from '~/utils/logError'; - -import { companyProgressesFamilyState } from '../../../../companies/states/companyProgressesFamilyState'; -import { - CompanyForBoard, - CompanyProgressDict, -} from '../../../../companies/types/CompanyProgress'; - -export const useUpdateCompanyBoardColumnsInternal = () => { - const { boardColumnsState, savedBoardColumnsState } = - useRecordBoardDeprecatedScopedStates(); - - return useRecoilCallback( - ({ set, snapshot }) => - ( - pipelineSteps: PipelineStep[], - opportunities: Opportunity[], - companies: CompanyForBoard[], - ) => { - const indexCompanyByIdReducer = ( - acc: { [key: string]: CompanyForBoard }, - company: CompanyForBoard, - ) => ({ - ...acc, - [company.id]: company, - }); - - const companiesDict = - companies.reduce( - indexCompanyByIdReducer, - {} as { [key: string]: CompanyForBoard }, - ) ?? {}; - - const indexOpportunityByIdReducer = ( - acc: CompanyProgressDict, - opportunity: Opportunity, - ) => { - const company = - opportunity.companyId && companiesDict[opportunity.companyId]; - - if (!company) return acc; - - return { - ...acc, - [opportunity.id]: { - opportunity, - company, - }, - }; - }; - - const companyBoardIndex = opportunities.reduce( - indexOpportunityByIdReducer, - {} as CompanyProgressDict, - ); - - for (const [id, companyProgress] of Object.entries(companyBoardIndex)) { - const currentCompanyProgress = snapshot - .getLoadable(companyProgressesFamilyState(id)) - .valueOrThrow(); - - if (!isDeeplyEqual(currentCompanyProgress, companyProgress)) { - set(companyProgressesFamilyState(id), companyProgress); - set(recordStoreFamilyState(id), companyProgress.opportunity); - } - } - - const currentPipelineSteps = snapshot - .getLoadable(currentPipelineStepsState) - .valueOrThrow(); - - const currentBoardColumns = snapshot - .getLoadable(boardColumnsState) - .valueOrThrow(); - - if (!isDeeplyEqual(pipelineSteps, currentPipelineSteps)) { - set(currentPipelineStepsState, pipelineSteps); - } - - const orderedPipelineSteps = [...pipelineSteps].sort((a, b) => { - if (!a.position || !b.position) return 0; - return a.position - b.position; - }); - - const newBoardColumns: BoardColumnDefinition[] = - orderedPipelineSteps?.map((pipelineStep) => { - const colorValidationResult = themeColorSchema.safeParse( - pipelineStep.color, - ); - - if (!colorValidationResult.success) { - logError( - `Color ${pipelineStep.color} is not recognized in useUpdateCompanyBoard.`, - ); - } - - return { - id: pipelineStep.id, - title: pipelineStep.name, - colorCode: colorValidationResult.success - ? colorValidationResult.data - : undefined, - position: pipelineStep.position ?? 0, - }; - }); - if ( - currentBoardColumns.length === 0 && - !isDeeplyEqual(newBoardColumns, currentBoardColumns) - ) { - set(boardColumnsState, newBoardColumns); - set(savedBoardColumnsState, newBoardColumns); - } - for (const boardColumn of newBoardColumns) { - const boardCardIds = opportunities - .filter( - (opportunityToFilter) => - opportunityToFilter.pipelineStepId === boardColumn.id, - ) - .map((opportunity) => opportunity.id); - - const currentBoardCardIds = snapshot - .getLoadable( - recordBoardCardIdsByColumnIdFamilyState(boardColumn.id), - ) - .valueOrThrow(); - - if (!isDeeplyEqual(currentBoardCardIds, boardCardIds)) { - set( - recordBoardCardIdsByColumnIdFamilyState(boardColumn.id), - boardCardIds, - ); - } - } - }, - [boardColumnsState, savedBoardColumnsState], - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/useRecordBoardDeprecated.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/useRecordBoardDeprecated.ts deleted file mode 100644 index 4a4e05b00d74..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/hooks/useRecordBoardDeprecated.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useSetRecoilState } from 'recoil'; - -import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { RecordBoardDeprecatedScopeInternalContext } from '@/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext'; -import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; - -type useRecordBoardDeprecatedProps = { - recordBoardScopeId?: string; -}; - -export const useRecordBoardDeprecated = ( - props?: useRecordBoardDeprecatedProps, -) => { - const scopeId = useAvailableScopeIdOrThrow( - RecordBoardDeprecatedScopeInternalContext, - props?.recordBoardScopeId, - ); - - const { isBoardLoadedState, boardColumnsState, onFieldsChangeState } = - useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: scopeId, - }); - const setIsBoardLoaded = useSetRecoilState(isBoardLoadedState); - - const setBoardColumns = useSetRecoilState(boardColumnsState); - - const createOpportunity = useCreateOpportunity(); - - const setOnFieldsChange = useSetRecoilState(onFieldsChangeState); - - return { - scopeId, - setIsBoardLoaded, - setBoardColumns, - createOpportunity, - setOnFieldsChange, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdown.tsx deleted file mode 100644 index 1a3e6f1f204d..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdown.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { BOARD_OPTIONS_DROPDOWN_ID } from '@/object-record/record-board-deprecated/constants/BoardOptionsDropdownId'; -import { useViewBar } from '@/views/hooks/useViewBar'; - -import { Dropdown } from '../../../../ui/layout/dropdown/components/Dropdown'; -import { BoardOptionsHotkeyScope } from '../../types/BoardOptionsHotkeyScope'; - -import { RecordBoardDeprecatedOptionsDropdownButton } from './RecordBoardDeprecatedOptionsDropdownButton'; -import { - RecordBoardDeprecatedOptionsDropdownContent, - RecordBoardDeprecatedOptionsDropdownContentProps, -} from './RecordBoardDeprecatedOptionsDropdownContent'; - -type RecordBoardDeprecatedOptionsDropdownProps = Pick< - RecordBoardDeprecatedOptionsDropdownContentProps, - 'onStageAdd' | 'recordBoardId' ->; - -export const RecordBoardDeprecatedOptionsDropdown = ({ - onStageAdd, - recordBoardId, -}: RecordBoardDeprecatedOptionsDropdownProps) => { - const { setViewEditMode } = useViewBar(); - - return ( - } - dropdownComponents={ - - } - dropdownHotkeyScope={{ scope: BoardOptionsHotkeyScope.Dropdown }} - onClickOutside={() => setViewEditMode('none')} - dropdownMenuWidth={170} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownButton.tsx deleted file mode 100644 index 254519c508cc..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownButton.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { BOARD_OPTIONS_DROPDOWN_ID } from '@/object-record/record-board-deprecated/constants/BoardOptionsDropdownId'; -import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; -import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; - -export const RecordBoardDeprecatedOptionsDropdownButton = () => { - const { isDropdownOpen, toggleDropdown } = useDropdown( - BOARD_OPTIONS_DROPDOWN_ID, - ); - - const handleClick = () => { - toggleDropdown(); - }; - - return ( - - Options - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx deleted file mode 100644 index 2290f75b4de0..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/options/components/RecordBoardDeprecatedOptionsDropdownContent.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { useCallback, useRef, useState } from 'react'; -import { OnDragEndResponder } from '@hello-pangea/dnd'; -import { useRecoilState, useRecoilValue } from 'recoil'; -import { Key } from 'ts-key-enum'; -import { v4 } from 'uuid'; - -import { BOARD_OPTIONS_DROPDOWN_ID } from '@/object-record/record-board-deprecated/constants/BoardOptionsDropdownId'; -import { useRecordBoardDeprecatedScopedStates } from '@/object-record/record-board-deprecated/hooks/internal/useRecordBoardDeprecatedScopedStates'; -import { - IconBaselineDensitySmall, - IconChevronLeft, - IconLayoutKanban, - IconPlus, - IconTag, -} from '@/ui/display/icon'; -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 { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; -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 { MenuItemNavigate } from '@/ui/navigation/menu-item/components/MenuItemNavigate'; -import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle'; -import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; -import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; -import { useViewBar } from '@/views/hooks/useViewBar'; -import { moveArrayItem } from '~/utils/array/moveArrayItem'; - -import { useRecordBoardDeprecatedCardFieldsInternal } from '../../hooks/internal/useRecordBoardDeprecatedCardFieldsInternal'; -import { BoardColumnDefinition } from '../../types/BoardColumnDefinition'; -import { BoardOptionsHotkeyScope } from '../../types/BoardOptionsHotkeyScope'; - -export type RecordBoardDeprecatedOptionsDropdownContentProps = { - onStageAdd?: (boardColumn: BoardColumnDefinition) => void; - recordBoardId: string; -}; - -type BoardOptionsMenu = 'fields' | 'stage-creation' | 'stages'; - -export const RecordBoardDeprecatedOptionsDropdownContent = ({ - onStageAdd, - recordBoardId, -}: RecordBoardDeprecatedOptionsDropdownContentProps) => { - const { setViewEditMode, handleViewNameSubmit } = useViewBar(); - const { viewEditModeState, currentViewSelector } = useViewScopedStates(); - - const viewEditMode = useRecoilValue(viewEditModeState); - const currentView = useRecoilValue(currentViewSelector); - - const stageInputRef = useRef(null); - const viewEditInputRef = useRef(null); - - const [currentMenu, setCurrentMenu] = useState< - BoardOptionsMenu | undefined - >(); - - const { - boardColumnsState, - isCompactViewEnabledState, - hiddenBoardCardFieldsSelector, - visibleBoardCardFieldsSelector, - } = useRecordBoardDeprecatedScopedStates({ - recordBoardScopeId: recordBoardId, - }); - - const [boardColumns, setBoardColumns] = useRecoilState(boardColumnsState); - const [isCompactViewEnabled, setIsCompactViewEnabled] = useRecoilState( - isCompactViewEnabledState, - ); - - const hiddenBoardCardFields = useRecoilValue(hiddenBoardCardFieldsSelector); - const hasHiddenFields = hiddenBoardCardFields.length > 0; - - const visibleBoardCardFields = useRecoilValue(visibleBoardCardFieldsSelector); - const hasVisibleFields = visibleBoardCardFields.length > 0; - - const handleStageSubmit = () => { - if (currentMenu !== 'stage-creation' || !stageInputRef?.current?.value) - return; - - const columnToCreate: BoardColumnDefinition = { - id: v4(), - colorCode: 'gray', - position: boardColumns.length, - title: stageInputRef.current.value, - }; - - setBoardColumns((previousBoardColumns) => [ - ...previousBoardColumns, - columnToCreate, - ]); - onStageAdd?.(columnToCreate); - }; - - const resetMenu = () => setCurrentMenu(undefined); - - const handleMenuNavigate = (menu: BoardOptionsMenu) => { - handleViewNameSubmit(); - setCurrentMenu(menu); - }; - - const { handleFieldVisibilityChange, handleFieldsReorder } = - useRecordBoardDeprecatedCardFieldsInternal({ - recordBoardScopeId: recordBoardId, - }); - - const { closeDropdown } = useDropdown(BOARD_OPTIONS_DROPDOWN_ID); - - const handleReorderField: OnDragEndResponder = useCallback( - (result) => { - if (!result.destination) { - return; - } - - const reorderedFields = moveArrayItem(visibleBoardCardFields, { - fromIndex: result.source.index - 1, - toIndex: result.destination.index - 1, - }); - - handleFieldsReorder(reorderedFields); - }, - [handleFieldsReorder, visibleBoardCardFields], - ); - - useScopedHotkeys( - [Key.Escape], - () => { - setViewEditMode('none'); - closeDropdown(); - }, - BoardOptionsHotkeyScope.Dropdown, - ); - - useScopedHotkeys( - Key.Enter, - () => { - const name = viewEditInputRef.current?.value; - resetMenu(); - setViewEditMode('none'); - handleStageSubmit(); - handleViewNameSubmit(name); - closeDropdown(); - }, - BoardOptionsHotkeyScope.Dropdown, - ); - - return ( - <> - {!currentMenu && ( - <> - - - - handleMenuNavigate('fields')} - LeftIcon={IconTag} - text="Fields" - /> - handleMenuNavigate('stages')} - LeftIcon={IconLayoutKanban} - text="Stages" - /> - - - - - - - )} - {currentMenu === 'stages' && ( - <> - - Stages - - - - setCurrentMenu('stage-creation')} - LeftIcon={IconPlus} - text="Add stage" - /> - - - )} - {currentMenu === 'stage-creation' && ( - - )} - {currentMenu === 'fields' && ( - <> - - Fields - - - {hasVisibleFields && ( - - )} - {hasVisibleFields && hasHiddenFields && } - {hasHiddenFields && ( - - )} - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope.tsx b/packages/twenty-front/src/modules/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope.tsx deleted file mode 100644 index d8e2f4a447e1..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/scopes/RecordBoardDeprecatedScope.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { ReactNode } from 'react'; - -import { RecordBoardDeprecatedScopeInternalContext } from '@/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext'; - -type RecordBoardDeprecatedScopeProps = { - children: ReactNode; - recordBoardScopeId: string; -}; - -export const RecordBoardDeprecatedScope = ({ - children, - recordBoardScopeId, -}: RecordBoardDeprecatedScopeProps) => { - return ( - - {children} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext.ts deleted file mode 100644 index 11531f68e6ee..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/scopes/scope-internal-context/RecordBoardDeprecatedScopeInternalContext.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; -import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; - -type RecordBoardDeprecatedScopeInternalContextProps = StateScopeMapKey; - -export const RecordBoardDeprecatedScopeInternalContext = - createScopeInternalContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/activeRecordBoardDeprecatedCardIdsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/activeRecordBoardDeprecatedCardIdsScopedState.ts deleted file mode 100644 index df9b9a158c4c..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/activeRecordBoardDeprecatedCardIdsScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const activeRecordBoardDeprecatedCardIdsScopedState = - createStateScopeMap({ - key: 'activeRecordBoardDeprecatedCardIdsScopedState', - defaultValue: [], - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/availableRecordBoardDeprecatedCardFieldsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/availableRecordBoardDeprecatedCardFieldsScopedState.ts deleted file mode 100644 index 3c397a042d75..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/availableRecordBoardDeprecatedCardFieldsScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { BoardFieldDefinition } from '../types/BoardFieldDefinition'; - -export const availableRecordBoardDeprecatedCardFieldsScopedState = - createStateScopeMap[]>({ - key: 'availableRecordBoardDeprecatedCardFieldsScopedState', - defaultValue: [], - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isCompactViewEnabledScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isCompactViewEnabledScopedState.ts deleted file mode 100644 index 2e7a35df21de..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isCompactViewEnabledScopedState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isCompactViewEnabledScopedState = createStateScopeMap({ - key: 'isCompactViewEnabledScopedState', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardInCompactViewFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardInCompactViewFamilyState.ts deleted file mode 100644 index 09220ce05cb1..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardInCompactViewFamilyState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atomFamily } from 'recoil'; - -export const isRecordBoardDeprecatedCardInCompactViewFamilyState = atomFamily< - boolean, - string ->({ - key: 'isRecordBoardDeprecatedCardInCompactViewFamilyState', - default: true, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardSelectedFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardSelectedFamilyState.ts deleted file mode 100644 index 84fce509c945..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedCardSelectedFamilyState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atomFamily } from 'recoil'; - -export const isRecordBoardDeprecatedCardSelectedFamilyState = atomFamily< - boolean, - string ->({ - key: 'isRecordBoardDeprecatedCardSelectedFamilyState', - default: false, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedLoadedScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedLoadedScopedState.ts deleted file mode 100644 index 4ac71da09d32..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/isRecordBoardDeprecatedLoadedScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isRecordBoardDeprecatedLoadedScopedState = - createStateScopeMap({ - key: 'isRecordBoardDeprecatedLoadedScopedState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/onFieldsChangeScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/onFieldsChangeScopedState.ts deleted file mode 100644 index d411b642a6ba..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/onFieldsChangeScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { BoardFieldDefinition } from '@/object-record/record-board-deprecated/types/BoardFieldDefinition'; -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const onFieldsChangeScopedState = createStateScopeMap< - (fields: BoardFieldDefinition[]) => void ->({ - key: 'onFieldsChangeScopedState', - defaultValue: () => {}, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState.ts deleted file mode 100644 index 0c462a3e6b17..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardCardIdsByColumnIdFamilyState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { atomFamily } from 'recoil'; - -export const recordBoardCardIdsByColumnIdFamilyState = atomFamily< - string[], - string ->({ - key: 'recordBoardCardIdsByColumnIdFamilyState', - default: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardColumnsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardColumnsScopedState.ts deleted file mode 100644 index 129330e98456..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardColumnsScopedState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardColumnsScopedState = createStateScopeMap< - BoardColumnDefinition[] ->({ - key: 'recordBoardColumnsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedCardFieldsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedCardFieldsScopedState.ts deleted file mode 100644 index 8bdefa0a20f7..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedCardFieldsScopedState.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { BoardFieldDefinition } from '../types/BoardFieldDefinition'; - -export const recordBoardCardFieldsScopedState = createStateScopeMap< - BoardFieldDefinition[] ->({ - key: 'recordBoardCardFieldsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedFiltersScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedFiltersScopedState.ts deleted file mode 100644 index a0d02927e5a3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedFiltersScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardFiltersScopedState = createStateScopeMap({ - key: 'recordBoardFiltersScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedSortsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedSortsScopedState.ts deleted file mode 100644 index b19b5f4837b5..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/recordBoardDeprecatedSortsScopedState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { Sort } from '../../object-sort-dropdown/types/Sort'; - -export const recordBoardSortsScopedState = createStateScopeMap({ - key: 'recordBoardSortsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedOpportunitiesScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedOpportunitiesScopedState.ts deleted file mode 100644 index 57443838c594..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedOpportunitiesScopedState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Opportunity } from '@/pipeline/types/Opportunity'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const savedOpportunitiesScopedState = createStateScopeMap( - { - key: 'savedOpportunitiesScopedState', - defaultValue: [], - }, -); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedPipelineStepsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedPipelineStepsScopedState.ts deleted file mode 100644 index 347ae076a233..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedPipelineStepsScopedState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { PipelineStep } from '@/pipeline/types/PipelineStep'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const savedPipelineStepsScopedState = createStateScopeMap< - PipelineStep[] ->({ - key: 'savedPipelineStepsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedCardFieldsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedCardFieldsScopedState.ts deleted file mode 100644 index 5d129d2b8887..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedCardFieldsScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { BoardFieldDefinition } from '../types/BoardFieldDefinition'; - -export const savedRecordBoardDeprecatedCardFieldsScopedState = - createStateScopeMap[]>({ - key: 'savedRecordBoardDeprecatedCardFieldsScopedState', - defaultValue: [], - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedColumnsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedColumnsScopedState.ts deleted file mode 100644 index d0e5e70c51c0..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedColumnsScopedState.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { BoardColumnDefinition } from '../types/BoardColumnDefinition'; - -export const savedRecordBoardDeprecatedColumnsScopedState = createStateScopeMap< - BoardColumnDefinition[] ->({ - key: 'savedRecordBoardDeprecatedColumnsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordsScopedState.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordsScopedState.ts deleted file mode 100644 index ac17469e4b98..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/savedRecordsScopedState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Company } from '@/companies/types/Company'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const savedRecordsScopedState = createStateScopeMap({ - key: 'savedRecordsScopedState', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/hiddenRecordBoardDeprecatedCardFieldsScopedSelector.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/hiddenRecordBoardDeprecatedCardFieldsScopedSelector.ts deleted file mode 100644 index d78a2c8347d6..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/hiddenRecordBoardDeprecatedCardFieldsScopedSelector.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -import { availableRecordBoardDeprecatedCardFieldsScopedState } from '../availableRecordBoardDeprecatedCardFieldsScopedState'; -import { recordBoardCardFieldsScopedState } from '../recordBoardDeprecatedCardFieldsScopedState'; - -export const hiddenRecordBoardDeprecatedCardFieldsScopedSelector = - createSelectorReadOnlyScopeMap({ - key: 'hiddenRecordBoardDeprecatedCardFieldsScopedSelector', - get: - ({ scopeId }) => - ({ get }) => { - const fields = get(recordBoardCardFieldsScopedState({ scopeId })); - const fieldKeys = fields.map(({ fieldMetadataId }) => fieldMetadataId); - - const otherAvailableKeys = get( - availableRecordBoardDeprecatedCardFieldsScopedState({ scopeId }), - ).filter(({ fieldMetadataId }) => !fieldKeys.includes(fieldMetadataId)); - - return [ - ...fields.filter((field) => !field.isVisible), - ...otherAvailableKeys, - ]; - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedCardFieldsByKeyScopedSelector.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedCardFieldsByKeyScopedSelector.ts deleted file mode 100644 index 63808d878732..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedCardFieldsByKeyScopedSelector.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { selectorFamily } from 'recoil'; - -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; - -import { BoardFieldDefinition } from '../../types/BoardFieldDefinition'; -import { recordBoardCardFieldsScopedState } from '../recordBoardDeprecatedCardFieldsScopedState'; - -export const recordBoardCardFieldsByKeyScopedSelector = selectorFamily({ - key: 'recordBoardCardFieldsByKeyScopedSelector', - get: - (scopeId: string) => - ({ get }) => - get(recordBoardCardFieldsScopedState({ scopeId })).reduce< - Record> - >((result, field) => ({ ...result, [field.fieldMetadataId]: field }), {}), -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedColumnTotalsFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedColumnTotalsFamilySelector.ts deleted file mode 100644 index a03d9b935ee2..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedColumnTotalsFamilySelector.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { selectorFamily } from 'recoil'; - -import { companyProgressesFamilyState } from '@/companies/states/companyProgressesFamilyState'; -import { amountFormat } from '~/utils/format/amountFormat'; - -import { recordBoardCardIdsByColumnIdFamilyState } from '../recordBoardCardIdsByColumnIdFamilyState'; - -// TODO: this state should be computed during the synchronization web-hook and put in a generic -// boardColumnTotalsFamilyState indexed by columnId. -export const recordBoardColumnTotalsFamilySelector = selectorFamily({ - key: 'recordBoardColumnTotalsFamilySelector', - get: - (pipelineStepId: string) => - ({ get }) => { - const cardIds = get( - recordBoardCardIdsByColumnIdFamilyState(pipelineStepId), - ); - - const opportunities = cardIds.map((opportunityId: string) => - get(companyProgressesFamilyState(opportunityId)), - ); - - const pipelineStepTotal: number = - opportunities?.reduce( - (acc: number, curr: any) => - acc + curr?.opportunity.amount.amountMicros / 1000000, - 0, - ) || 0; - - return amountFormat(pipelineStepTotal); - }, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/selectedRecordBoardDeprecatedCardIdsScopedSelector.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/selectedRecordBoardDeprecatedCardIdsScopedSelector.ts deleted file mode 100644 index b4f65e584cdc..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/selectedRecordBoardDeprecatedCardIdsScopedSelector.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -import { isRecordBoardDeprecatedCardSelectedFamilyState } from '../isRecordBoardDeprecatedCardSelectedFamilyState'; -import { recordBoardCardIdsByColumnIdFamilyState } from '../recordBoardCardIdsByColumnIdFamilyState'; -import { recordBoardColumnsScopedState } from '../recordBoardColumnsScopedState'; - -export const selectedRecordBoardDeprecatedCardIdsScopedSelector = - createSelectorReadOnlyScopeMap({ - key: 'selectedRecordBoardDeprecatedCardIdsScopedSelector', - get: - ({ scopeId }) => - ({ get }) => { - const boardColumns = get(recordBoardColumnsScopedState({ scopeId })); - - const cardIds = boardColumns.flatMap((boardColumn) => - get(recordBoardCardIdsByColumnIdFamilyState(boardColumn.id)), - ); - - const selectedCardIds = cardIds.filter( - (cardId) => - get(isRecordBoardDeprecatedCardSelectedFamilyState(cardId)) === - true, - ); - - return selectedCardIds; - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/visibleRecordBoardDeprecatedCardFieldsScopedSelector.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/visibleRecordBoardDeprecatedCardFieldsScopedSelector.ts deleted file mode 100644 index de9e1cba1871..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/states/selectors/visibleRecordBoardDeprecatedCardFieldsScopedSelector.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -import { recordBoardCardFieldsScopedState } from '../recordBoardDeprecatedCardFieldsScopedState'; - -export const visibleRecordBoardDeprecatedCardFieldsScopedSelector = - createSelectorReadOnlyScopeMap({ - key: 'visibleRecordBoardDeprecatedCardFieldsScopedSelector', - get: - ({ scopeId }) => - ({ get }) => - get(recordBoardCardFieldsScopedState({ scopeId })) - .filter((field) => field.isVisible) - .sort((a, b) => a.position - b.position), - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardColumnDefinition.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardColumnDefinition.ts deleted file mode 100644 index b779ed4358b9..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardColumnDefinition.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { ThemeColor } from '@/ui/theme/constants/MainColorNames'; - -export type BoardColumnDefinition = { - id: string; - title: string; - position: number; - colorCode?: ThemeColor; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardColumnHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardColumnHotkeyScope.ts deleted file mode 100644 index 25663b4e337d..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardColumnHotkeyScope.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum BoardColumnHotkeyScope { - BoardColumn = 'board-column', -} diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardFieldDefinition.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardFieldDefinition.ts deleted file mode 100644 index fc4c1e25b141..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardFieldDefinition.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; - -export type BoardFieldDefinition = - FieldDefinition & { - position: number; - isVisible?: boolean; - viewFieldId?: string; - }; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardOptions.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardOptions.ts deleted file mode 100644 index 73390947703e..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardOptions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ComponentType } from 'react'; - -export type BoardOptions = { - newCardComponent: React.ReactNode; - CardComponent: ComponentType; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardOptionsHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardOptionsHotkeyScope.ts deleted file mode 100644 index f726bc66f5f3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/BoardOptionsHotkeyScope.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum BoardOptionsHotkeyScope { - Dropdown = 'board-options-dropdown', -} diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/ColumnHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/ColumnHotkeyScope.ts deleted file mode 100644 index 9e490fe403a9..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/types/ColumnHotkeyScope.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum ColumnHotkeyScope { - EditColumnName = 'EditColumnNameHotkeyScope', -} diff --git a/packages/twenty-front/src/modules/object-record/record-board-deprecated/utils/getRecordBoardDeprecatedScopedStates.ts b/packages/twenty-front/src/modules/object-record/record-board-deprecated/utils/getRecordBoardDeprecatedScopedStates.ts deleted file mode 100644 index 087a7ff51e10..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board-deprecated/utils/getRecordBoardDeprecatedScopedStates.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { activeRecordBoardDeprecatedCardIdsScopedState } from '@/object-record/record-board-deprecated/states/activeRecordBoardDeprecatedCardIdsScopedState'; -import { availableRecordBoardDeprecatedCardFieldsScopedState } from '@/object-record/record-board-deprecated/states/availableRecordBoardDeprecatedCardFieldsScopedState'; -import { isCompactViewEnabledScopedState } from '@/object-record/record-board-deprecated/states/isCompactViewEnabledScopedState'; -import { isRecordBoardDeprecatedLoadedScopedState } from '@/object-record/record-board-deprecated/states/isRecordBoardDeprecatedLoadedScopedState'; -import { onFieldsChangeScopedState } from '@/object-record/record-board-deprecated/states/onFieldsChangeScopedState'; -import { recordBoardColumnsScopedState } from '@/object-record/record-board-deprecated/states/recordBoardColumnsScopedState'; -import { recordBoardFiltersScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedFiltersScopedState'; -import { recordBoardSortsScopedState } from '@/object-record/record-board-deprecated/states/recordBoardDeprecatedSortsScopedState'; -import { savedOpportunitiesScopedState } from '@/object-record/record-board-deprecated/states/savedOpportunitiesScopedState'; -import { savedPipelineStepsScopedState } from '@/object-record/record-board-deprecated/states/savedPipelineStepsScopedState'; -import { savedRecordBoardDeprecatedColumnsScopedState } from '@/object-record/record-board-deprecated/states/savedRecordBoardDeprecatedColumnsScopedState'; -import { savedRecordsScopedState } from '@/object-record/record-board-deprecated/states/savedRecordsScopedState'; -import { hiddenRecordBoardDeprecatedCardFieldsScopedSelector } from '@/object-record/record-board-deprecated/states/selectors/hiddenRecordBoardDeprecatedCardFieldsScopedSelector'; -import { recordBoardCardFieldsByKeyScopedSelector } from '@/object-record/record-board-deprecated/states/selectors/recordBoardDeprecatedCardFieldsByKeyScopedSelector'; -import { selectedRecordBoardDeprecatedCardIdsScopedSelector } from '@/object-record/record-board-deprecated/states/selectors/selectedRecordBoardDeprecatedCardIdsScopedSelector'; -import { visibleRecordBoardDeprecatedCardFieldsScopedSelector } from '@/object-record/record-board-deprecated/states/selectors/visibleRecordBoardDeprecatedCardFieldsScopedSelector'; -import { getScopedStateDeprecated } from '@/ui/utilities/recoil-scope/utils/getScopedStateDeprecated'; - -export const getRecordBoardDeprecatedScopedStates = ({ - recordBoardScopeId, -}: { - recordBoardScopeId: string; -}) => { - const activeCardIdsState = getScopedStateDeprecated( - activeRecordBoardDeprecatedCardIdsScopedState, - recordBoardScopeId, - ); - - const availableBoardCardFieldsState = getScopedStateDeprecated( - availableRecordBoardDeprecatedCardFieldsScopedState, - recordBoardScopeId, - ); - - const boardColumnsState = getScopedStateDeprecated( - recordBoardColumnsScopedState, - recordBoardScopeId, - ); - - const isBoardLoadedState = getScopedStateDeprecated( - isRecordBoardDeprecatedLoadedScopedState, - recordBoardScopeId, - ); - - const isCompactViewEnabledState = getScopedStateDeprecated( - isCompactViewEnabledScopedState, - recordBoardScopeId, - ); - - const savedBoardColumnsState = getScopedStateDeprecated( - savedRecordBoardDeprecatedColumnsScopedState, - recordBoardScopeId, - ); - - const boardFiltersState = getScopedStateDeprecated( - recordBoardFiltersScopedState, - recordBoardScopeId, - ); - - const boardSortsState = getScopedStateDeprecated( - recordBoardSortsScopedState, - recordBoardScopeId, - ); - - const savedCompaniesState = getScopedStateDeprecated( - savedRecordsScopedState, - recordBoardScopeId, - ); - - const savedOpportunitiesState = getScopedStateDeprecated( - savedOpportunitiesScopedState, - recordBoardScopeId, - ); - - const savedPipelineStepsState = getScopedStateDeprecated( - savedPipelineStepsScopedState, - recordBoardScopeId, - ); - - const onFieldsChangeState = getScopedStateDeprecated( - onFieldsChangeScopedState, - recordBoardScopeId, - ); - - // TODO: Family scoped selector - const boardCardFieldsByKeySelector = - recordBoardCardFieldsByKeyScopedSelector(recordBoardScopeId); - - const hiddenBoardCardFieldsSelector = - hiddenRecordBoardDeprecatedCardFieldsScopedSelector({ - scopeId: recordBoardScopeId, - }); - - const selectedCardIdsSelector = - selectedRecordBoardDeprecatedCardIdsScopedSelector({ - scopeId: recordBoardScopeId, - }); - - const visibleBoardCardFieldsSelector = - visibleRecordBoardDeprecatedCardFieldsScopedSelector({ - scopeId: recordBoardScopeId, - }); - - return { - activeCardIdsState, - availableBoardCardFieldsState, - boardColumnsState, - isBoardLoadedState, - isCompactViewEnabledState, - savedBoardColumnsState, - boardFiltersState, - boardSortsState, - onFieldsChangeState, - boardCardFieldsByKeySelector, - hiddenBoardCardFieldsSelector, - selectedCardIdsSelector, - visibleBoardCardFieldsSelector, - savedCompaniesState, - savedOpportunitiesState, - savedPipelineStepsState, - }; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx index d0d810222011..584cbeab3b5f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/action-bar/components/RecordBoardActionBar.tsx @@ -10,13 +10,13 @@ type RecordBoardActionBarProps = { export const RecordBoardActionBar = ({ recordBoardId, }: RecordBoardActionBarProps) => { - const { getSelectedRecordIdsSelector } = useRecordBoardStates(recordBoardId); + const { selectedRecordIdsSelector } = useRecordBoardStates(recordBoardId); - const selectedRecordIds = useRecoilValue(getSelectedRecordIdsSelector()); + const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); if (!selectedRecordIds.length) { return null; } - return ; + return ; }; 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 f8aa66909a56..b8840686e104 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 @@ -26,8 +26,6 @@ const StyledContainer = styled.div` display: flex; flex: 1; flex-direction: row; - margin-left: ${({ theme }) => theme.spacing(2)}; - margin-right: ${({ theme }) => theme.spacing(2)}; `; const StyledWrapper = styled.div` @@ -50,12 +48,12 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { const boardRef = useRef(null); const { - getColumnIdsState, + columnIdsState, columnsFamilySelector, recordIdsByColumnIdFamilyState, } = useRecordBoardStates(recordBoardId); - const columnIds = useRecoilValue(getColumnIdsState()); + const columnIds = useRecoilValue(columnIdsState); const { resetRecordSelection, setRecordAsSelected } = useRecordBoardSelection(recordBoardId); diff --git a/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx index b98245d90165..fed35a6506d3 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/context-menu/components/RecordBoardContextMenu.tsx @@ -10,9 +10,9 @@ type RecordBoardContextMenuProps = { export const RecordBoardContextMenu = ({ recordBoardId, }: RecordBoardContextMenuProps) => { - const { getSelectedRecordIdsSelector } = useRecordBoardStates(recordBoardId); + const { selectedRecordIdsSelector } = useRecordBoardStates(recordBoardId); - const selectedRecordIds = useRecoilValue(getSelectedRecordIdsSelector()); + const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); if (!selectedRecordIds.length) { return null; 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 291db2655e47..7758ca97fae1 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,24 +1,25 @@ import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; -import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap'; -import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap'; -import { isRecordBoardCardSelectedFamilyStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap'; -import { isRecordBoardCompactModeActiveStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap'; -import { isRecordBoardFetchingRecordsStateScopeMap } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap'; -import { onRecordBoardFetchMoreVisibilityChangeStateScopeMap } from '@/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap'; -import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; -import { recordBoardFieldDefinitionsStateScopeMap } from '@/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap'; -import { recordBoardFiltersStateScopeMap } from '@/object-record/record-board/states/recordBoardFiltersStateScopeMap'; -import { recordBoardObjectSingularNameStateScopeMap } from '@/object-record/record-board/states/recordBoardObjectSingularNameStateScopeMap'; -import { recordBoardRecordIdsByColumnIdFamilyStateScopeMap } from '@/object-record/record-board/states/recordBoardRecordIdsByColumnIdFamilyStateScopeMap'; -import { recordBoardSortsStateScopeMap } from '@/object-record/record-board/states/recordBoardSortsStateScopeMap'; -import { recordBoardColumnsFamilySelectorScopeMap } from '@/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap'; -import { recordBoardSelectedRecordIdsSelectorScopeMap } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsSelectorScopeMap'; -import { recordBoardVisibleFieldDefinitionsScopedSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsScopedSelector'; +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 { isRecordBoardFetchingRecordsComponentState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsComponentState'; +import { onRecordBoardFetchMoreVisibilityChangeComponentState } from '@/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeComponentState'; +import { recordBoardColumnIdsComponentState } from '@/object-record/record-board/states/recordBoardColumnIdsComponentState'; +import { recordBoardFieldDefinitionsComponentState } from '@/object-record/record-board/states/recordBoardFieldDefinitionsComponentState'; +import { recordBoardFiltersComponentState } from '@/object-record/record-board/states/recordBoardFiltersComponentState'; +import { recordBoardKanbanFieldMetadataNameComponentState } from '@/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState'; +import { recordBoardObjectSingularNameComponentState } from '@/object-record/record-board/states/recordBoardObjectSingularNameComponentState'; +import { recordBoardRecordIdsByColumnIdComponentFamilyState } from '@/object-record/record-board/states/recordBoardRecordIdsByColumnIdComponentFamilyState'; +import { recordBoardSortsComponentState } from '@/object-record/record-board/states/recordBoardSortsComponentState'; +import { recordBoardColumnsComponentFamilySelector } from '@/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector'; +import { recordBoardSelectedRecordIdsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector'; +import { recordBoardVisibleFieldDefinitionsComponentSelector } from '@/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; -import { getFamilyState } from '@/ui/utilities/recoil-scope/utils/getFamilyState'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; -import { getSelectorReadOnly } from '@/ui/utilities/recoil-scope/utils/getSelectorReadOnly'; -import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; +import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState'; +import { extractComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/extractComponentReadOnlySelector'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; export const useRecordBoardStates = (recordBoardId?: string) => { const scopeId = useAvailableScopeIdOrThrow( @@ -28,59 +29,69 @@ export const useRecordBoardStates = (recordBoardId?: string) => { return { scopeId, - getObjectSingularNameState: getState( - recordBoardObjectSingularNameStateScopeMap, + objectSingularNameState: extractComponentState( + recordBoardObjectSingularNameComponentState, scopeId, ), - getIsFetchingRecordState: getState( - isRecordBoardFetchingRecordsStateScopeMap, + kanbanFieldMetadataNameState: extractComponentState( + recordBoardKanbanFieldMetadataNameComponentState, scopeId, ), - getColumnIdsState: getState(recordBoardColumnIdsStateScopeMap, scopeId), - isFirstColumnFamilyState: getFamilyState( - isFirstRecordBoardColumnFamilyStateScopeMap, + isFetchingRecordState: extractComponentState( + isRecordBoardFetchingRecordsComponentState, scopeId, ), - isLastColumnFamilyState: getFamilyState( - isLastRecordBoardColumnFamilyStateScopeMap, + columnIdsState: extractComponentState( + recordBoardColumnIdsComponentState, scopeId, ), - columnsFamilySelector: getFamilyState( - recordBoardColumnsFamilySelectorScopeMap, + isFirstColumnFamilyState: extractComponentFamilyState( + isFirstRecordBoardColumnComponentFamilyState, + scopeId, + ), + isLastColumnFamilyState: extractComponentFamilyState( + isLastRecordBoardColumnComponentFamilyState, + scopeId, + ), + columnsFamilySelector: extractComponentFamilyState( + recordBoardColumnsComponentFamilySelector, scopeId, ), - getFiltersState: getState(recordBoardFiltersStateScopeMap, scopeId), - getSortsState: getState(recordBoardSortsStateScopeMap, scopeId), - getFieldDefinitionsState: getState( - recordBoardFieldDefinitionsStateScopeMap, + filtersState: extractComponentState( + recordBoardFiltersComponentState, + scopeId, + ), + sortsState: extractComponentState(recordBoardSortsComponentState, scopeId), + fieldDefinitionsState: extractComponentState( + recordBoardFieldDefinitionsComponentState, scopeId, ), - getVisibleFieldDefinitionsState: getSelectorReadOnly( - recordBoardVisibleFieldDefinitionsScopedSelector, + visibleFieldDefinitionsState: extractComponentReadOnlySelector( + recordBoardVisibleFieldDefinitionsComponentSelector, scopeId, ), - recordIdsByColumnIdFamilyState: getFamilyState( - recordBoardRecordIdsByColumnIdFamilyStateScopeMap, + recordIdsByColumnIdFamilyState: extractComponentFamilyState( + recordBoardRecordIdsByColumnIdComponentFamilyState, scopeId, ), - isRecordBoardCardSelectedFamilyState: getFamilyState( - isRecordBoardCardSelectedFamilyStateScopeMap, + isRecordBoardCardSelectedFamilyState: extractComponentFamilyState( + isRecordBoardCardSelectedComponentFamilyState, scopeId, ), - getSelectedRecordIdsSelector: getSelectorReadOnly( - recordBoardSelectedRecordIdsSelectorScopeMap, + selectedRecordIdsSelector: extractComponentReadOnlySelector( + recordBoardSelectedRecordIdsComponentSelector, scopeId, ), - getIsCompactModeActiveState: getState( - isRecordBoardCompactModeActiveStateScopeMap, + isCompactModeActiveState: extractComponentState( + isRecordBoardCompactModeActiveComponentState, scopeId, ), - getOnFetchMoreVisibilityChangeState: getState( - onRecordBoardFetchMoreVisibilityChangeStateScopeMap, + onFetchMoreVisibilityChangeState: extractComponentState( + onRecordBoardFetchMoreVisibilityChangeComponentState, 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 34ca406203a9..d330a476d64f 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 @@ -5,14 +5,14 @@ import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/ import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const useSetRecordBoardColumns = (recordBoardId?: string) => { - const { scopeId, getColumnIdsState, columnsFamilySelector } = + const { scopeId, columnIdsState, columnsFamilySelector } = useRecordBoardStates(recordBoardId); const setColumns = useRecoilCallback( ({ set, snapshot }) => (columns: RecordBoardColumnDefinition[]) => { const currentColumnsIds = snapshot - .getLoadable(getColumnIdsState()) + .getLoadable(columnIdsState) .getValue(); const columnIds = columns.map(({ id }) => id); @@ -22,7 +22,7 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => { } set( - getColumnIdsState(), + columnIdsState, columns.map((column) => column.id), ); @@ -38,7 +38,7 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => { set(columnsFamilySelector(column.id), column); }); }, - [columnsFamilySelector, getColumnIdsState], + [columnsFamilySelector, columnIdsState], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts index db2811af0f35..8ead970305d8 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardRecordIds.ts @@ -9,13 +9,14 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { scopeId, recordIdsByColumnIdFamilyState, columnsFamilySelector, - getColumnIdsState, + columnIdsState, + kanbanFieldMetadataNameState, } = useRecordBoardStates(recordBoardId); const setRecordIds = useRecoilCallback( ({ set, snapshot }) => (records: ObjectRecord[]) => { - const columnIds = snapshot.getLoadable(getColumnIdsState()).getValue(); + const columnIds = snapshot.getLoadable(columnIdsState).getValue(); columnIds.forEach((columnId) => { const column = snapshot @@ -26,8 +27,19 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { .getLoadable(recordIdsByColumnIdFamilyState(columnId)) .getValue(); + const kanbanFieldMetadataName = snapshot + .getLoadable(kanbanFieldMetadataNameState) + .getValue(); + + if (!kanbanFieldMetadataName) { + return; + } + const columnRecordIds = records - .filter((record) => record.stage === column?.value) + .filter( + (record) => record[kanbanFieldMetadataName] === column?.value, + ) + .sort(sortRecordsByPosition) .map((record) => record.id); if (!isDeeplyEqual(existingColumnRecordIds, columnRecordIds)) { @@ -35,7 +47,12 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { } }); }, - [columnsFamilySelector, getColumnIdsState, recordIdsByColumnIdFamilyState], + [ + columnIdsState, + columnsFamilySelector, + recordIdsByColumnIdFamilyState, + kanbanFieldMetadataNameState, + ], ); return { @@ -43,3 +60,21 @@ export const useSetRecordBoardRecordIds = (recordBoardId?: string) => { setRecordIds, }; }; + +const sortRecordsByPosition = ( + record1: ObjectRecord, + record2: ObjectRecord, +) => { + if ( + typeof record1.position == 'number' && + typeof record2.position == 'number' + ) { + return record1.position - record2.position; + } else if (record1.position === 'first' || record2.position === 'last') { + return -1; + } else if (record2.position === 'first' || record1.position === 'last') { + return 1; + } else { + return 0; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts index 59429b254652..25a358e7ac91 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoard.ts @@ -7,17 +7,21 @@ import { useSetRecordBoardRecordIds } from '@/object-record/record-board/hooks/i export const useRecordBoard = (recordBoardId?: string) => { const { scopeId, - getFieldDefinitionsState, - getObjectSingularNameState, - getSelectedRecordIdsSelector, - getIsCompactModeActiveState, - getOnFetchMoreVisibilityChangeState, + fieldDefinitionsState, + objectSingularNameState, + selectedRecordIdsSelector, + isCompactModeActiveState, + onFetchMoreVisibilityChangeState, + kanbanFieldMetadataNameState, } = useRecordBoardStates(recordBoardId); const { setColumns } = useSetRecordBoardColumns(recordBoardId); const { setRecordIds } = useSetRecordBoardRecordIds(recordBoardId); - const setFieldDefinitions = useSetRecoilState(getFieldDefinitionsState()); - const setObjectSingularName = useSetRecoilState(getObjectSingularNameState()); + const setFieldDefinitions = useSetRecoilState(fieldDefinitionsState); + const setObjectSingularName = useSetRecoilState(objectSingularNameState); + const setKanbanFieldMetadataName = useSetRecoilState( + kanbanFieldMetadataNameState, + ); return { scopeId, @@ -25,8 +29,9 @@ export const useRecordBoard = (recordBoardId?: string) => { setRecordIds, setFieldDefinitions, setObjectSingularName, - getSelectedRecordIdsSelector, - getIsCompactModeActiveState, - getOnFetchMoreVisibilityChangeState, + setKanbanFieldMetadataName, + selectedRecordIdsSelector, + isCompactModeActiveState, + onFetchMoreVisibilityChangeState, }; }; 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 ae89223c79ee..a2024d4ba683 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 @@ -3,21 +3,21 @@ import { useRecoilCallback } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; export const useRecordBoardSelection = (recordBoardId?: string) => { - const { getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } = + const { selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } = useRecordBoardStates(recordBoardId); const resetRecordSelection = useRecoilCallback( ({ snapshot, set }) => () => { const recordIds = snapshot - .getLoadable(getSelectedRecordIdsSelector()) + .getLoadable(selectedRecordIdsSelector()) .getValue(); for (const recordId of recordIds) { set(isRecordBoardCardSelectedFamilyState(recordId), false); } }, - [getSelectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState], + [selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState], ); const setRecordAsSelected = useRecoilCallback( 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 15f2424e97cc..0c753862a180 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 @@ -2,6 +2,7 @@ import { ReactNode, useContext, useState } from 'react'; import { useInView } from 'react-intersection-observer'; import styled from '@emotion/styled'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; +import { IconEye } from 'twenty-ui'; import { RecordChip } from '@/object-record/components/RecordChip'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; @@ -15,7 +16,6 @@ import { import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { IconEye } from '@/ui/display/icon/index'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; @@ -133,12 +133,12 @@ export const RecordBoardCard = () => { const { updateOneRecord, objectMetadataItem } = useContext(RecordBoardContext); const { - getIsCompactModeActiveState, + isCompactModeActiveState, isRecordBoardCardSelectedFamilyState, - getVisibleFieldDefinitionsState, + visibleFieldDefinitionsState, } = useRecordBoardStates(); - const isCompactModeActive = useRecoilValue(getIsCompactModeActiveState()); + const isCompactModeActive = useRecoilValue(isCompactModeActiveState); const [isCardInCompactMode, setIsCardInCompactMode] = useState(true); @@ -146,8 +146,8 @@ export const RecordBoardCard = () => { isRecordBoardCardSelectedFamilyState(recordId), ); - const visibleBoardCardFieldDefinitions = useRecoilValue( - getVisibleFieldDefinitionsState(), + const visibleFieldDefinitions = useRecoilValue( + visibleFieldDefinitionsState(), ); const record = useRecoilValue(recordStoreFamilyState(recordId)); @@ -247,7 +247,7 @@ export const RecordBoardCard = () => { isOpen={!isCardInCompactMode || !isCompactModeActive} initial={false} > - {visibleBoardCardFieldDefinitions.map((fieldDefinition) => ( + {visibleFieldDefinitions.map((fieldDefinition) => ( @@ -264,6 +264,7 @@ export const RecordBoardCard = () => { iconName: fieldDefinition.iconName, type: fieldDefinition.type, metadata: fieldDefinition.metadata, + defaultValue: fieldDefinition.defaultValue, }, useUpdateRecord: useUpdateOneRecordHook, hotkeyScope: InlineCellHotkeyScope.InlineCell, 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 0b2750fd9b2c..e08feba7a50d 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,10 +1,10 @@ import { useCallback, useContext, useRef } from 'react'; import styled from '@emotion/styled'; -import { MenuItem } from 'tsup.ui.index'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; 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'; const StyledMenuContainer = styled.div` diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx index 072120d68063..56127562c05b 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnFetchMoreLoader.tsx @@ -16,12 +16,12 @@ const StyledText = styled.div` `; export const RecordBoardColumnFetchMoreLoader = () => { - const { getIsFetchingRecordState, getOnFetchMoreVisibilityChangeState } = + const { isFetchingRecordState, onFetchMoreVisibilityChangeState } = useRecordBoardStates(); - const isFetchingRecords = useRecoilValue(getIsFetchingRecordState()); + const isFetchingRecord = useRecoilValue(isFetchingRecordState); const onFetchMoreVisibilityChange = useRecoilValue( - getOnFetchMoreVisibilityChangeState(), + onFetchMoreVisibilityChangeState, ); const { ref } = useInView({ @@ -30,7 +30,7 @@ export const RecordBoardColumnFetchMoreLoader = () => { return (
- {isFetchingRecords && Loading more...} + {isFetchingRecord && Loading more...}
); }; 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 d1e36a3ecc69..25d6f7c1b5c3 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,10 +1,10 @@ import React, { useContext, useState } from 'react'; import styled from '@emotion/styled'; +import { IconDotsVertical } from 'twenty-ui'; 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 { BoardColumnHotkeyScope } from '@/object-record/record-board-deprecated/types/BoardColumnHotkeyScope'; -import { IconDotsVertical } from '@/ui/display/icon'; +import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope'; import { Tag } from '@/ui/display/tag/components/Tag'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; @@ -57,9 +57,12 @@ export const RecordBoardColumnHeader = () => { const handleBoardColumnMenuOpen = () => { setIsBoardColumnMenuOpen(true); - setHotkeyScopeAndMemorizePreviousScope(BoardColumnHotkeyScope.BoardColumn, { - goto: false, - }); + setHotkeyScopeAndMemorizePreviousScope( + RecordBoardColumnHotkeyScope.BoardColumn, + { + goto: false, + }, + ); }; const handleBoardColumnMenuClose = () => { 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/RecordBoardColumnNewButton.tsx index deac52c76d3e..62ce7d5f5a9b 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/RecordBoardColumnNewButton.tsx @@ -1,10 +1,10 @@ import { useContext } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconPlus } from 'twenty-ui'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; -import { IconPlus } from '@/ui/display/icon/index'; const StyledButton = styled.button` align-items: center; @@ -32,6 +32,7 @@ export const RecordBoardColumnNewButton = () => { const onNewClick = () => { createOneRecord({ [selectFieldMetadataItem.name]: columnDefinition.value, + position: 'last', }); }; 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 index 9cac6da4ff73..398990c5c7c6 100644 --- 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 @@ -1,6 +1,7 @@ import { useCallback, useContext, useState } from 'react'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { IconPlus } from 'twenty-ui'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; @@ -8,7 +9,6 @@ import { RecordBoardColumnContext } from '@/object-record/record-board/record-bo import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; -import { IconPlus } from '@/ui/display/icon'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; const StyledButton = styled.button` @@ -52,6 +52,7 @@ export const RecordBoardColumnNewOpportunityButton = () => { createOneRecord({ name: company.name, companyId: company.id, + position: 'last', [selectFieldMetadataItem.name]: columnDefinition.value, }); }; 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 03da78cce764..22aeae0edc3b 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,10 +1,10 @@ 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 { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; +import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; -type RecordBoardScopeInternalContextProps = StateScopeMapKey & { +type RecordBoardScopeInternalContextProps = ComponentStateKey & { onFieldsChange: (fields: FieldDefinition[]) => void; onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; }; 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 new file mode 100644 index 000000000000..bef4219700d4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts @@ -0,0 +1,7 @@ +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/isFirstRecordBoardColumnFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap.ts deleted file mode 100644 index 989f3277b8c0..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -export const isFirstRecordBoardColumnFamilyStateScopeMap = - createFamilyStateScopeMap({ - key: 'isFirstRecordBoardColumnFamilyStateScopeMap', - 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 new file mode 100644 index 000000000000..9174fba1cac2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts @@ -0,0 +1,7 @@ +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/isLastRecordBoardColumnFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap.ts deleted file mode 100644 index b29e9ecaeb4d..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -export const isLastRecordBoardColumnFamilyStateScopeMap = - createFamilyStateScopeMap({ - key: 'isLastRecordBoardColumnFamilyStateScopeMap', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState.ts new file mode 100644 index 000000000000..8cb7c99f4959 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState.ts @@ -0,0 +1,7 @@ +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const isRecordBoardCardSelectedComponentFamilyState = + createComponentFamilyState({ + key: 'isRecordBoardCardSelectedComponentFamilyState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap.ts deleted file mode 100644 index 825e46ed3ca3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -export const isRecordBoardCardSelectedFamilyStateScopeMap = - createFamilyStateScopeMap({ - key: 'isRecordBoardCardSelectedFamilyStateScopeMap', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState.ts new file mode 100644 index 000000000000..68741ee81a90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isRecordBoardCompactModeActiveComponentState = + createComponentState({ + key: 'isRecordBoardCompactModeActiveComponentState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap.ts deleted file mode 100644 index 0ad51b9a9844..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardCompactModeActiveStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isRecordBoardCompactModeActiveStateScopeMap = - createStateScopeMap({ - key: 'isRecordBoardCompactModeActiveStateScopeMap', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsComponentState.ts new file mode 100644 index 000000000000..c76a8777e328 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isRecordBoardFetchingRecordsComponentState = + createComponentState({ + key: 'isRecordBoardFetchingRecordsComponentState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts deleted file mode 100644 index 83c35a4be906..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isRecordBoardFetchingRecordsStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isRecordBoardFetchingRecordsStateScopeMap = - createStateScopeMap({ - key: 'isRecordBoardFetchingRecordsStateScopeMap', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeComponentState.ts new file mode 100644 index 000000000000..c977c8851b41 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const onRecordBoardFetchMoreVisibilityChangeComponentState = + createComponentState<(visbility: boolean) => void>({ + key: 'onRecordBoardFetchMoreVisibilityChangeComponentState', + defaultValue: () => {}, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts deleted file mode 100644 index fb5da620aa7d..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/onRecordBoardFetchMoreVisibilityChangeStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const onRecordBoardFetchMoreVisibilityChangeStateScopeMap = - createStateScopeMap<(visbility: boolean) => void>({ - key: 'onRecordBoardFetchMoreVisibilityChangeStateScopeMap', - defaultValue: () => {}, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsComponentState.ts new file mode 100644 index 000000000000..3ae094376c22 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardColumnIdsComponentState = createComponentState< + string[] +>({ + key: 'recordBoardColumnIdsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts deleted file mode 100644 index 82118caf3485..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnIdsStateScopeMap.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardColumnIdsStateScopeMap = createStateScopeMap({ - key: 'recordBoardColumnIdsStateScopeMap', - defaultValue: [], -}); 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 new file mode 100644 index 000000000000..c2b6cc1cfe5d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts @@ -0,0 +1,8 @@ +import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const recordBoardColumnsComponentFamilyState = + createComponentFamilyState({ + key: 'recordBoardColumnsComponentFamilyState', + defaultValue: undefined, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts deleted file mode 100644 index aca09ce52f72..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -export const recordBoardColumnsFamilyStateScopeMap = createFamilyStateScopeMap< - RecordBoardColumnDefinition | undefined, - string ->({ - key: 'recordBoardColumnsFamilyStateScopeMap', - defaultValue: undefined, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFieldDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFieldDefinitionsComponentState.ts new file mode 100644 index 000000000000..e8fb862be662 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFieldDefinitionsComponentState.ts @@ -0,0 +1,10 @@ +import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition'; +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardFieldDefinitionsComponentState = createComponentState< + RecordBoardFieldDefinition[] +>({ + key: 'recordBoardFieldDefinitionsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap.ts deleted file mode 100644 index 4248350bcebd..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { RecordBoardFieldDefinition } from '@/object-record/record-board/types/RecordBoardFieldDefinition'; -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardFieldDefinitionsStateScopeMap = createStateScopeMap< - RecordBoardFieldDefinition[] ->({ - key: 'recordBoardFieldDefinitionsStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFiltersComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFiltersComponentState.ts new file mode 100644 index 000000000000..7d493b349e5b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFiltersComponentState.ts @@ -0,0 +1,7 @@ +import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardFiltersComponentState = createComponentState({ + key: 'recordBoardFiltersComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFiltersStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFiltersStateScopeMap.ts deleted file mode 100644 index 71c8528ceb7f..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardFiltersStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardFiltersStateScopeMap = createStateScopeMap({ - key: 'recordBoardFiltersStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState.ts new file mode 100644 index 000000000000..26490c9298b3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardKanbanFieldMetadataNameComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardKanbanFieldMetadataNameComponentState = + createComponentState({ + key: 'recordBoardKanbanFieldMetadataNameComponentState', + defaultValue: undefined, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardObjectSingularNameComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardObjectSingularNameComponentState.ts new file mode 100644 index 000000000000..b3efddfd7655 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardObjectSingularNameComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardObjectSingularNameComponentState = createComponentState< + string | undefined +>({ + key: 'recordBoardObjectSingularNameComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardObjectSingularNameStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardObjectSingularNameStateScopeMap.ts deleted file mode 100644 index fd35c826a242..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardObjectSingularNameStateScopeMap.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardObjectSingularNameStateScopeMap = createStateScopeMap< - string | undefined ->({ - key: 'recordBoardObjectSingularNameStateScopeMap', - defaultValue: undefined, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardRecordIdsByColumnIdComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardRecordIdsByColumnIdComponentFamilyState.ts new file mode 100644 index 000000000000..9cb2f0df334a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardRecordIdsByColumnIdComponentFamilyState.ts @@ -0,0 +1,7 @@ +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const recordBoardRecordIdsByColumnIdComponentFamilyState = + createComponentFamilyState({ + key: 'recordBoardRecordIdsByColumnIdComponentFamilyState', + defaultValue: [], + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardRecordIdsByColumnIdFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardRecordIdsByColumnIdFamilyStateScopeMap.ts deleted file mode 100644 index 8a413e0b5de3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardRecordIdsByColumnIdFamilyStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -export const recordBoardRecordIdsByColumnIdFamilyStateScopeMap = - createFamilyStateScopeMap({ - key: 'recordBoardRecordIdsByColumnIdFamilyStateScopeMap', - defaultValue: [], - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardSortsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardSortsComponentState.ts new file mode 100644 index 000000000000..d2aa0923f335 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardSortsComponentState.ts @@ -0,0 +1,7 @@ +import { Sort } from '@/object-record/object-sort-dropdown/types/Sort'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordBoardSortsComponentState = createComponentState({ + key: 'recordBoardSortsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardSortsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardSortsStateScopeMap.ts deleted file mode 100644 index b49376153a49..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardSortsStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Sort } from '@/object-record/object-sort-dropdown/types/Sort'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordBoardSortsStateScopeMap = createStateScopeMap({ - key: 'recordBoardSortsStateScopeMap', - defaultValue: [], -}); 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 new file mode 100644 index 000000000000..22dd7aa8df66 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts @@ -0,0 +1,118 @@ +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'; + +export const recordBoardColumnsComponentFamilySelector = + createComponentFamilySelector< + RecordBoardColumnDefinition | undefined, + string + >({ + key: 'recordBoardColumnsComponentFamilySelector', + get: + ({ + scopeId, + familyKey: columnId, + }: { + scopeId: string; + familyKey: string; + }) => + ({ get }) => { + return get( + recordBoardColumnsComponentFamilyState({ + scopeId, + familyKey: columnId, + }), + ); + }, + set: + ({ + scopeId, + familyKey: columnId, + }: { + scopeId: string; + familyKey: string; + }) => + ({ set, get }, newColumn) => { + set( + recordBoardColumnsComponentFamilyState({ + scopeId, + familyKey: columnId, + }), + 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/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts deleted file mode 100644 index c2ab6ed95be7..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsFamilySelectorScopeMap.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { isFirstRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isFirstRecordBoardColumnFamilyStateScopeMap'; -import { isLastRecordBoardColumnFamilyStateScopeMap } from '@/object-record/record-board/states/isLastRecordBoardColumnFamilyStateScopeMap'; -import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; -import { recordBoardColumnsFamilyStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnsFamilyStateScopeMap'; -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; -import { createFamilySelectorScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilySelectorScopeMap'; -import { guardRecoilDefaultValue } from '@/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue'; -import { isNonNullable } from '~/utils/isNonNullable'; - -export const recordBoardColumnsFamilySelectorScopeMap = - createFamilySelectorScopeMap( - { - key: 'recordBoardColumnsFamilySelectorScopeMap', - get: - ({ - scopeId, - familyKey: columnId, - }: { - scopeId: string; - familyKey: string; - }) => - ({ get }) => { - return get( - recordBoardColumnsFamilyStateScopeMap({ - scopeId, - familyKey: columnId, - }), - ); - }, - set: - ({ - scopeId, - familyKey: columnId, - }: { - scopeId: string; - familyKey: string; - }) => - ({ set, get }, newColumn) => { - set( - recordBoardColumnsFamilyStateScopeMap({ - scopeId, - familyKey: columnId, - }), - newColumn, - ); - - if (guardRecoilDefaultValue(newColumn)) return; - - const columnIds = get(recordBoardColumnIdsStateScopeMap({ scopeId })); - - const columns = columnIds - .map((columnId) => { - return get( - recordBoardColumnsFamilyStateScopeMap({ - scopeId, - familyKey: columnId, - }), - ); - }) - .filter(isNonNullable); - - 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( - isLastRecordBoardColumnFamilyStateScopeMap({ - scopeId, - familyKey: columnId, - }), - true, - ); - - if (lastColumn) { - set( - isLastRecordBoardColumnFamilyStateScopeMap({ - scopeId, - familyKey: lastColumn.id, - }), - false, - ); - } - } - - if (!firstColumn || newColumn.position < firstColumn.position) { - set( - isFirstRecordBoardColumnFamilyStateScopeMap({ - scopeId, - familyKey: columnId, - }), - true, - ); - - if (firstColumn) { - set( - isFirstRecordBoardColumnFamilyStateScopeMap({ - scopeId, - familyKey: firstColumn.id, - }), - false, - ); - } - } - }, - }, - ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector.ts new file mode 100644 index 000000000000..ce2b1e4cac4d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsComponentSelector.ts @@ -0,0 +1,35 @@ +import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; +import { recordBoardColumnIdsComponentState } from '@/object-record/record-board/states/recordBoardColumnIdsComponentState'; +import { recordBoardRecordIdsByColumnIdComponentFamilyState } from '@/object-record/record-board/states/recordBoardRecordIdsByColumnIdComponentFamilyState'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; + +export const recordBoardSelectedRecordIdsComponentSelector = + createComponentReadOnlySelector({ + key: 'recordBoardSelectedRecordIdsSelector', + get: + ({ scopeId }) => + ({ get }) => { + const columnIds = get(recordBoardColumnIdsComponentState({ scopeId })); + + const recordIdsByColumn = columnIds.map((columnId) => + get( + recordBoardRecordIdsByColumnIdComponentFamilyState({ + scopeId, + familyKey: columnId, + }), + ), + ); + + const recordIds = recordIdsByColumn.flat(); + + return recordIds.filter( + (recordId) => + get( + isRecordBoardCardSelectedComponentFamilyState({ + scopeId, + familyKey: recordId, + }), + ) === true, + ); + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsSelectorScopeMap.ts deleted file mode 100644 index 437fcfa56365..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardSelectedRecordIdsSelectorScopeMap.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { isRecordBoardCardSelectedFamilyStateScopeMap } from '@/object-record/record-board/states/isRecordBoardCardSelectedFamilyStateScopeMap'; -import { recordBoardColumnIdsStateScopeMap } from '@/object-record/record-board/states/recordBoardColumnIdsStateScopeMap'; -import { recordBoardRecordIdsByColumnIdFamilyStateScopeMap } from '@/object-record/record-board/states/recordBoardRecordIdsByColumnIdFamilyStateScopeMap'; -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -export const recordBoardSelectedRecordIdsSelectorScopeMap = - createSelectorReadOnlyScopeMap({ - key: 'recordBoardSelectedRecordIdsSelectorScopeMap', - get: - ({ scopeId }) => - ({ get }) => { - const columnIds = get(recordBoardColumnIdsStateScopeMap({ scopeId })); - - const recordIdsByColumn = columnIds.map((columnId) => - get( - recordBoardRecordIdsByColumnIdFamilyStateScopeMap({ - scopeId, - familyKey: columnId, - }), - ), - ); - - const recordIds = recordIdsByColumn.flat(); - - return recordIds.filter( - (recordId) => - get( - isRecordBoardCardSelectedFamilyStateScopeMap({ - scopeId, - familyKey: recordId, - }), - ) === true, - ); - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector.ts new file mode 100644 index 000000000000..4b2732eb36d8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsComponentSelector.ts @@ -0,0 +1,13 @@ +import { recordBoardFieldDefinitionsComponentState } from '@/object-record/record-board/states/recordBoardFieldDefinitionsComponentState'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; + +export const recordBoardVisibleFieldDefinitionsComponentSelector = + createComponentReadOnlySelector({ + key: 'recordBoardVisibleFieldDefinitionsComponentSelector', + get: + ({ scopeId }) => + ({ get }) => + get(recordBoardFieldDefinitionsComponentState({ scopeId })) + .filter((field) => field.isVisible) + .sort((a, b) => a.position - b.position), + }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsScopedSelector.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsScopedSelector.ts deleted file mode 100644 index edae8d879c59..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardVisibleFieldDefinitionsScopedSelector.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { recordBoardFieldDefinitionsStateScopeMap } from '@/object-record/record-board/states/recordBoardFieldDefinitionsStateScopeMap'; -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -export const recordBoardVisibleFieldDefinitionsScopedSelector = - createSelectorReadOnlyScopeMap({ - key: 'recordBoardVisibleFieldDefinitionsScopedSelector', - get: - ({ scopeId }) => - ({ get }) => - get(recordBoardFieldDefinitionsStateScopeMap({ scopeId })) - .filter((field) => field.isVisible) - .sort((a, b) => a.position - b.position), - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts b/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts new file mode 100644 index 000000000000..c62d939355fe --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/types/BoardColumnHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum RecordBoardColumnHotkeyScope { + BoardColumn = 'board-column', +} 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 8b842656a63e..4a85b26f246f 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 @@ -7,6 +7,7 @@ import { FieldSelectMetadata, FieldTextMetadata, } from '@/object-record/record-field/types/FieldMetadata'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { mockedCompaniesMetadata, mockedCustomMetadata, @@ -34,7 +35,7 @@ export const textfieldDefinition: FieldDefinition = { fieldMetadataId, label: 'User Name', iconName: 'User', - type: 'TEXT', + type: FieldMetadataType.Text, metadata: { placeHolder: 'John Doe', fieldName: 'userName' }, }; @@ -52,7 +53,7 @@ export const selectFieldDefinition: FieldDefinition = { fieldMetadataId, label: 'Account Owner', iconName: 'iconName', - type: 'SELECT', + type: FieldMetadataType.Select, metadata: { fieldName: 'accountOwner', options: [{ label: 'Elon Musk', color: 'blue', value: 'userId' }], @@ -63,7 +64,7 @@ export const fullNameFieldDefinition: FieldDefinition = { fieldMetadataId, label: 'Display Name', iconName: 'profile', - type: 'FULL_NAME', + type: FieldMetadataType.FullName, metadata: { fieldName: 'displayName', placeHolder: 'Mr Miagi', @@ -74,7 +75,7 @@ export const linkFieldDefinition: FieldDefinition = { fieldMetadataId, label: 'LinkedIn URL', iconName: 'url', - type: 'LINK', + type: FieldMetadataType.Link, metadata: { fieldName: 'linkedInURL', placeHolder: 'https://linkedin.com/user', @@ -93,7 +94,7 @@ export const ratingfieldDefinition: FieldDefinition = { fieldMetadataId, label: 'Rating', iconName: 'iconName', - type: 'RATING', + type: FieldMetadataType.Rating, metadata: { fieldName: 'rating', }, diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx index 93efc40017cf..68999b4fc169 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldDisplay.tsx @@ -1,25 +1,33 @@ import { useContext } from 'react'; import { FieldContext } from '../contexts/FieldContext'; +import { AddressFieldDisplay } from '../meta-types/display/components/AddressFieldDisplay'; import { ChipFieldDisplay } from '../meta-types/display/components/ChipFieldDisplay'; import { CurrencyFieldDisplay } from '../meta-types/display/components/CurrencyFieldDisplay'; import { DateFieldDisplay } from '../meta-types/display/components/DateFieldDisplay'; +import { DateTimeFieldDisplay } from '../meta-types/display/components/DateTimeFieldDisplay'; import { EmailFieldDisplay } from '../meta-types/display/components/EmailFieldDisplay'; import { FullNameFieldDisplay } from '../meta-types/display/components/FullNameFieldDisplay'; +import { JsonFieldDisplay } from '../meta-types/display/components/JsonFieldDisplay'; import { LinkFieldDisplay } from '../meta-types/display/components/LinkFieldDisplay'; +import { MultiSelectFieldDisplay } from '../meta-types/display/components/MultiSelectFieldDisplay.tsx'; import { NumberFieldDisplay } from '../meta-types/display/components/NumberFieldDisplay'; import { PhoneFieldDisplay } from '../meta-types/display/components/PhoneFieldDisplay'; import { RelationFieldDisplay } from '../meta-types/display/components/RelationFieldDisplay'; import { SelectFieldDisplay } from '../meta-types/display/components/SelectFieldDisplay'; import { TextFieldDisplay } from '../meta-types/display/components/TextFieldDisplay'; import { UuidFieldDisplay } from '../meta-types/display/components/UuidFieldDisplay'; +import { isFieldAddress } from '../types/guards/isFieldAddress'; import { isFieldCurrency } from '../types/guards/isFieldCurrency'; +import { isFieldDate } from '../types/guards/isFieldDate'; import { isFieldDateTime } from '../types/guards/isFieldDateTime'; import { isFieldEmail } from '../types/guards/isFieldEmail'; import { isFieldFullName } from '../types/guards/isFieldFullName'; import { isFieldLink } from '../types/guards/isFieldLink'; +import { isFieldMultiSelect } from '../types/guards/isFieldMultiSelect.ts'; import { isFieldNumber } from '../types/guards/isFieldNumber'; import { isFieldPhone } from '../types/guards/isFieldPhone'; +import { isFieldRawJson } from '../types/guards/isFieldRawJson'; import { isFieldRelation } from '../types/guards/isFieldRelation'; import { isFieldSelect } from '../types/guards/isFieldSelect'; import { isFieldText } from '../types/guards/isFieldText'; @@ -28,13 +36,18 @@ import { isFieldUuid } from '../types/guards/isFieldUuid'; export const FieldDisplay = () => { const { fieldDefinition, isLabelIdentifier } = useContext(FieldContext); - return isLabelIdentifier && + const isChipDisplay = + isLabelIdentifier && (isFieldText(fieldDefinition) || isFieldFullName(fieldDefinition) || - isFieldNumber(fieldDefinition)) ? ( + isFieldNumber(fieldDefinition)); + + return isChipDisplay ? ( ) : isFieldRelation(fieldDefinition) ? ( + ) : isFieldPhone(fieldDefinition) ? ( + ) : isFieldText(fieldDefinition) ? ( ) : isFieldUuid(fieldDefinition) ? ( @@ -42,6 +55,8 @@ export const FieldDisplay = () => { ) : isFieldEmail(fieldDefinition) ? ( ) : isFieldDateTime(fieldDefinition) ? ( + + ) : isFieldDate(fieldDefinition) ? ( ) : isFieldNumber(fieldDefinition) ? ( @@ -51,9 +66,13 @@ export const FieldDisplay = () => { ) : isFieldFullName(fieldDefinition) ? ( - ) : isFieldPhone(fieldDefinition) ? ( - ) : isFieldSelect(fieldDefinition) ? ( + ) : isFieldMultiSelect(fieldDefinition) ? ( + + ) : isFieldAddress(fieldDefinition) ? ( + + ) : isFieldRawJson(fieldDefinition) ? ( + ) : null; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index 40c59eacd49a..d2a28e6a776e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -1,16 +1,23 @@ import { useContext } from 'react'; +import { AddressFieldInput } from '@/object-record/record-field/meta-types/input/components/AddressFieldInput'; +import { DateFieldInput } from '@/object-record/record-field/meta-types/input/components/DateFieldInput'; import { FullNameFieldInput } from '@/object-record/record-field/meta-types/input/components/FullNameFieldInput'; +import { MultiSelectFieldInput } from '@/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx'; +import { RawJsonFieldInput } from '@/object-record/record-field/meta-types/input/components/RawJsonFieldInput'; import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/components/SelectFieldInput'; import { RecordFieldInputScope } from '@/object-record/record-field/scopes/RecordFieldInputScope'; +import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; +import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { FieldContext } from '../contexts/FieldContext'; import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput'; import { CurrencyFieldInput } from '../meta-types/input/components/CurrencyFieldInput'; -import { DateFieldInput } from '../meta-types/input/components/DateFieldInput'; +import { DateTimeFieldInput } from '../meta-types/input/components/DateTimeFieldInput'; import { EmailFieldInput } from '../meta-types/input/components/EmailFieldInput'; import { LinkFieldInput } from '../meta-types/input/components/LinkFieldInput'; import { NumberFieldInput } from '../meta-types/input/components/NumberFieldInput'; @@ -19,6 +26,7 @@ import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInpu import { RelationFieldInput } from '../meta-types/input/components/RelationFieldInput'; import { TextFieldInput } from '../meta-types/input/components/TextFieldInput'; import { FieldInputEvent } from '../types/FieldInputEvent'; +import { isFieldAddress } from '../types/guards/isFieldAddress'; import { isFieldBoolean } from '../types/guards/isFieldBoolean'; import { isFieldCurrency } from '../types/guards/isFieldCurrency'; import { isFieldDateTime } from '../types/guards/isFieldDateTime'; @@ -39,6 +47,7 @@ type FieldInputProps = { onEscape?: FieldInputEvent; onTab?: FieldInputEvent; onShiftTab?: FieldInputEvent; + isReadOnly?: boolean; }; export const FieldInput = ({ @@ -50,6 +59,7 @@ export const FieldInput = ({ onShiftTab, onTab, onClickOutside, + isReadOnly, }: FieldInputProps) => { const { fieldDefinition } = useContext(FieldContext); @@ -92,6 +102,12 @@ export const FieldInput = ({ onShiftTab={onShiftTab} /> ) : isFieldDateTime(fieldDefinition) ? ( + + ) : isFieldDate(fieldDefinition) ? ( ) : isFieldBoolean(fieldDefinition) ? ( - + ) : isFieldRating(fieldDefinition) ? ( ) : isFieldSelect(fieldDefinition) ? ( - + + ) : isFieldMultiSelect(fieldDefinition) ? ( + + ) : isFieldAddress(fieldDefinition) ? ( + + ) : isFieldRawJson(fieldDefinition) ? ( + ) : ( <> )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useGetButtonIcon.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useGetButtonIcon.test.tsx index 6e8266aee336..877c7897ee02 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useGetButtonIcon.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useGetButtonIcon.test.tsx @@ -1,6 +1,7 @@ import { ReactNode } from 'react'; import { renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; +import { IconPencil } from 'twenty-ui'; import { phoneFieldDefinition, @@ -10,7 +11,6 @@ import { FieldContext } from '@/object-record/record-field/contexts/FieldContext import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { IconPencil } from '@/ui/display/icon'; const entityId = 'entityId'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx index 84da3f62d381..ff14e362df67 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/usePersistField.test.tsx @@ -20,14 +20,31 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinit import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: () => () => '\n', -})); - const query = gql` - mutation UpdateOnePerson($idToUpdate: ID!, $input: PersonUpdateInput!) { + mutation UpdateOnePerson($idToUpdate: UUID!, $input: PersonUpdateInput!) { updatePerson(id: $idToUpdate, data: $input) { + __typename + xLink { + label + url + } id + createdAt + city + email + jobTitle + name { + firstName + lastName + } + phone + linkedinLink { + label + url + } + updatedAt + avatarUrl + companyId } } `; 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 f07739124d55..0d17aaa11eaa 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 @@ -14,10 +14,6 @@ import { } from '@/object-record/record-field/contexts/FieldContext'; import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput'; -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: () => () => '\n', -})); - const entityId = 'entityId'; const mocks: MockedResponse[] = [ @@ -25,11 +21,32 @@ const mocks: MockedResponse[] = [ request: { query: gql` mutation UpdateOneCompany( - $idToUpdate: ID! + $idToUpdate: UUID! $input: CompanyUpdateInput! ) { updateCompany(id: $idToUpdate, data: $input) { + __typename + xLink { + label + url + } + linkedinLink { + label + url + } + domainName + annualRecurringRevenue { + amountMicros + currencyCode + } + createdAt + address + updatedAt + name + accountOwnerId + employees id + idealCustomerProfile } } `, diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/internal/useRecordFieldInputStates.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/internal/useRecordFieldInputStates.ts index 8efcb745d7ab..924e838b71af 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/internal/useRecordFieldInputStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/internal/useRecordFieldInputStates.ts @@ -1,9 +1,9 @@ import { RecordFieldInputScopeInternalContext } from '@/object-record/record-field/scopes/scope-internal-context/RecordFieldInputScopeInternalContext'; -import { recordFieldInputDraftValueSelectorScopeMap } from '@/object-record/record-field/states/selectors/recordFieldInputDraftValueSelectorScopeMap'; +import { recordFieldInputDraftValueComponentSelector } from '@/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector'; import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; -import { getSelector } from '@/ui/utilities/recoil-scope/utils/getSelector'; +import { extractComponentSelector } from '@/ui/utilities/state/component-state/utils/extractComponentSelector'; export const useRecordFieldInputStates = ( recordFieldInputId?: string, @@ -15,8 +15,8 @@ export const useRecordFieldInputStates = ( return { scopeId, - getDraftValueSelector: getSelector< + getDraftValueSelector: extractComponentSelector< FieldInputDraftValue | undefined - >(recordFieldInputDraftValueSelectorScopeMap, scopeId), + >(recordFieldInputDraftValueComponentSelector, scopeId), }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts new file mode 100644 index 000000000000..548e212f98c7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useClearField.ts @@ -0,0 +1,62 @@ +import { useContext } from 'react'; +import { useRecoilCallback } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; + +import { FieldContext } from '../contexts/FieldContext'; + +export const useClearField = () => { + const { + entityId, + fieldDefinition, + useUpdateRecord = () => [], + } = useContext(FieldContext); + + const [updateRecord] = useUpdateRecord(); + + const clearField = useRecoilCallback( + ({ snapshot, set }) => + () => { + const objectMetadataItems = snapshot + .getLoadable(objectMetadataItemsState) + .getValue(); + + const foundObjectMetadataItem = objectMetadataItems.find( + (item) => + item.nameSingular === + fieldDefinition.metadata.objectMetadataNameSingular, + ); + + const foundFieldMetadataItem = foundObjectMetadataItem?.fields.find( + (field) => field.name === fieldDefinition.metadata.fieldName, + ); + + if (!foundObjectMetadataItem || !foundFieldMetadataItem) { + throw new Error('Field metadata item cannot be found'); + } + + const fieldName = fieldDefinition.metadata.fieldName; + + const emptyFieldValue = generateEmptyFieldValue(foundFieldMetadataItem); + + set( + recordStoreFamilySelector({ recordId: entityId, fieldName }), + emptyFieldValue, + ); + + updateRecord?.({ + variables: { + where: { id: entityId }, + updateOneRecordInput: { + [fieldName]: emptyFieldValue, + }, + }, + }); + }, + [entityId, fieldDefinition, updateRecord], + ); + + return clearField; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts index 18ca7ecb3969..4dde37b2e163 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useGetButtonIcon.ts @@ -1,8 +1,9 @@ import { useContext } from 'react'; +import { IconPencil } from 'twenty-ui'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; -import { IconPencil } from '@/ui/display/icon'; import { IconComponent } from '@/ui/display/icon/types/IconComponent'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; import { FieldContext } from '../contexts/FieldContext'; import { isFieldEmail } from '../types/guards/isFieldEmail'; @@ -12,7 +13,7 @@ import { isFieldPhone } from '../types/guards/isFieldPhone'; export const useGetButtonIcon = (): IconComponent | undefined => { const { fieldDefinition } = useContext(FieldContext); - if (!fieldDefinition) return undefined; + if (isUndefinedOrNull(fieldDefinition)) return undefined; if ( isFieldLink(fieldDefinition) || diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldClearable.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldClearable.ts new file mode 100644 index 000000000000..0afd8aa91acb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldClearable.ts @@ -0,0 +1,21 @@ +import { useContext } from 'react'; + +import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime'; + +import { FieldContext } from '../contexts/FieldContext'; + +// TODO: have a better clearable settings in metadata ? +// We might want to define what's clearable in the metadata +// Instead of passing it in the context +// See: https://github.com/twentyhq/twenty/issues/4403 +export const useIsFieldClearable = (): boolean => { + const { clearable, isLabelIdentifier, fieldDefinition } = + useContext(FieldContext); + + const isDateField = isFieldDateTime(fieldDefinition); + + const fieldCanBeCleared = + !isLabelIdentifier && !isDateField && clearable !== false; + + return fieldCanBeCleared; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldInputOnly.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldInputOnly.ts index d1566823ce98..750ea8b0b463 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldInputOnly.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/useIsFieldInputOnly.ts @@ -7,9 +7,5 @@ import { isFieldRating } from '../types/guards/isFieldRating'; export const useIsFieldInputOnly = () => { const { fieldDefinition } = useContext(FieldContext); - if (isFieldBoolean(fieldDefinition) || isFieldRating(fieldDefinition)) { - return true; - } - - return false; + return isFieldBoolean(fieldDefinition) || isFieldRating(fieldDefinition); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts index aea51e8a629d..2e18b77f067d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/usePersistField.ts @@ -1,8 +1,16 @@ import { useContext } from 'react'; import { useRecoilCallback } from 'recoil'; +import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; +import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; +import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; +import { isFieldDateValue } from '@/object-record/record-field/types/guards/isFieldDateValue'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; +import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts'; +import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; +import { isFieldRawJsonValue } from '@/object-record/record-field/types/guards/isFieldRawJsonValue'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; @@ -55,6 +63,9 @@ export const usePersistField = () => { isFieldDateTime(fieldDefinition) && isFieldDateTimeValue(valueToPersist); + const fieldIsDate = + isFieldDate(fieldDefinition) && isFieldDateValue(valueToPersist); + const fieldIsLink = isFieldLink(fieldDefinition) && isFieldLinkValue(valueToPersist); @@ -82,7 +93,19 @@ export const usePersistField = () => { const fieldIsSelect = isFieldSelect(fieldDefinition) && isFieldSelectValue(valueToPersist); - if ( + const fieldIsMultiSelect = + isFieldMultiSelect(fieldDefinition) && + isFieldMultiSelectValue(valueToPersist); + + const fieldIsAddress = + isFieldAddress(fieldDefinition) && + isFieldAddressValue(valueToPersist); + + const fieldIsRawJson = + isFieldRawJson(fieldDefinition) && + isFieldRawJsonValue(valueToPersist); + + const isValuePersistable = fieldIsRelation || fieldIsText || fieldIsBoolean || @@ -90,18 +113,36 @@ export const usePersistField = () => { fieldIsProbability || fieldIsNumber || fieldIsDateTime || + fieldIsDate || fieldIsPhone || fieldIsLink || fieldIsCurrency || fieldIsFullName || - fieldIsSelect - ) { + fieldIsSelect || + fieldIsMultiSelect || + fieldIsAddress || + fieldIsRawJson; + + if (isValuePersistable === true) { const fieldName = fieldDefinition.metadata.fieldName; set( recordStoreFamilySelector({ recordId: entityId, fieldName }), valueToPersist, ); + if (fieldIsRelation) { + updateRecord?.({ + variables: { + where: { id: entityId }, + updateOneRecordInput: { + [fieldName]: valueToPersist, + [`${fieldName}Id`]: valueToPersist?.id ?? null, + }, + }, + }); + return; + } + updateRecord?.({ variables: { where: { id: entityId }, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx new file mode 100644 index 000000000000..59805290042e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/AddressFieldDisplay.tsx @@ -0,0 +1,17 @@ +import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; +import { TextDisplay } from '@/ui/field/display/components/TextDisplay'; + +export const AddressFieldDisplay = () => { + const { fieldValue } = useAddressField(); + + const content = [ + fieldValue?.addressStreet1, + fieldValue?.addressStreet2, + fieldValue?.addressCity, + fieldValue?.addressCountry, + ] + .filter(Boolean) + .join(', '); + + return ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx index 75e4030e51bf..5a17caefc71b 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/CurrencyFieldDisplay.tsx @@ -5,11 +5,5 @@ import { useCurrencyField } from '../../hooks/useCurrencyField'; export const CurrencyFieldDisplay = () => { const { fieldValue } = useCurrencyField(); - return ( - - ); + return ; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateFieldDisplay.tsx index 975ac30a0f4e..8b6cd134f515 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateFieldDisplay.tsx @@ -1,9 +1,9 @@ import { DateDisplay } from '@/ui/field/display/components/DateDisplay'; -import { useDateTimeField } from '../../hooks/useDateTimeField'; +import { useDateField } from '../../hooks/useDateField'; export const DateFieldDisplay = () => { - const { fieldValue } = useDateTimeField(); + const { fieldValue } = useDateField(); return ; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay.tsx new file mode 100644 index 000000000000..cffbd0473c5b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/DateTimeFieldDisplay.tsx @@ -0,0 +1,9 @@ +import { DateDisplay } from '@/ui/field/display/components/DateDisplay'; + +import { useDateTimeField } from '../../hooks/useDateTimeField'; + +export const DateTimeFieldDisplay = () => { + const { fieldValue } = useDateTimeField(); + + return ; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx new file mode 100644 index 000000000000..5a0f553cde44 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/JsonFieldDisplay.tsx @@ -0,0 +1,13 @@ +import { useJsonField } from '@/object-record/record-field/meta-types/hooks/useJsonField'; +import { JsonDisplay } from '@/ui/field/display/components/JsonDisplay'; + +export const JsonFieldDisplay = () => { + const { fieldValue, maxWidth } = useJsonField(); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx new file mode 100644 index 000000000000..7f885da43376 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/MultiSelectFieldDisplay.tsx @@ -0,0 +1,32 @@ +import styled from '@emotion/styled'; + +import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts'; +import { Tag } from '@/ui/display/tag/components/Tag'; + +const StyledTagContainer = styled.div` + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; +export const MultiSelectFieldDisplay = () => { + const { fieldValues, fieldDefinition } = useMultiSelectField(); + + const selectedOptions = fieldValues + ? fieldDefinition.metadata.options.filter((option) => + fieldValues.includes(option.value), + ) + : []; + + return selectedOptions ? ( + + {selectedOptions.map((selectedOption, index) => ( + + ))} + + ) : ( + <> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/ChipFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/ChipFieldDisplay.stories.tsx index 7b963b45c9a7..f1eaf2aa5203 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/ChipFieldDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/ChipFieldDisplay.stories.tsx @@ -5,6 +5,7 @@ import { useSetRecoilState } from 'recoil'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { ChipFieldDisplay } from '@/object-record/record-field/meta-types/display/components/ChipFieldDisplay'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { FieldMetadataType } from '~/generated/graphql'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; @@ -37,7 +38,7 @@ const meta: Meta = { fieldDefinition: { fieldMetadataId: 'full name', label: 'Henry Cavill', - type: 'FULL_NAME', + type: FieldMetadataType.FullName, iconName: 'IconCalendarEvent', metadata: { fieldName: 'full name', diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/DateFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/DateFieldDisplay.stories.tsx deleted file mode 100644 index 3e2ae7bfeff3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/DateFieldDisplay.stories.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useEffect } from 'react'; -import { Meta, StoryObj } from '@storybook/react'; - -import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; - -import { FieldContext } from '../../../../contexts/FieldContext'; -import { useDateTimeField } from '../../../hooks/useDateTimeField'; -import { DateFieldDisplay } from '../DateFieldDisplay'; - -const formattedDate = new Date('2023-04-01'); - -const DateFieldValueSetterEffect = ({ value }: { value: string }) => { - const { setFieldValue } = useDateTimeField(); - - useEffect(() => { - setFieldValue(value); - }, [setFieldValue, value]); - - return null; -}; - -const meta: Meta = { - title: 'UI/Data/Field/Display/DateFieldDisplay', - decorators: [ - (Story, { args }) => ( - - - - - ), - ComponentDecorator, - ], - component: DateFieldDisplay, - argTypes: { value: { control: 'date' } }, - args: { - value: formattedDate, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = {}; - -export const Elipsis: Story = { - parameters: { - container: { width: 50 }, - }, -}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/DateTimeFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/DateTimeFieldDisplay.stories.tsx new file mode 100644 index 000000000000..b34ecbaf49f0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/DateTimeFieldDisplay.stories.tsx @@ -0,0 +1,67 @@ +import { useEffect } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; + +import { FieldMetadataType } from '~/generated/graphql'; +import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; + +import { FieldContext } from '../../../../contexts/FieldContext'; +import { useDateTimeField } from '../../../hooks/useDateTimeField'; +import { DateTimeFieldDisplay } from '../DateTimeFieldDisplay'; + +const formattedDate = new Date('2023-04-01'); + +const DateFieldValueSetterEffect = ({ value }: { value: string }) => { + const { setFieldValue } = useDateTimeField(); + + useEffect(() => { + setFieldValue(value); + }, [setFieldValue, value]); + + return null; +}; + +const meta: Meta = { + title: 'UI/Data/Field/Display/DateFieldDisplay', + decorators: [ + (Story, { args }) => ( + + + + + ), + ComponentDecorator, + ], + component: DateTimeFieldDisplay, + argTypes: { value: { control: 'date' } }, + args: { + value: formattedDate, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Elipsis: Story = { + parameters: { + container: { width: 50 }, + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/EmailFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/EmailFieldDisplay.stories.tsx index 902eefa4b32f..d296465e68dc 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/EmailFieldDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/EmailFieldDisplay.stories.tsx @@ -3,6 +3,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { useEmailField } from '@/object-record/record-field/meta-types/hooks/useEmailField'; +import { FieldMetadataType } from '~/generated/graphql'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; @@ -30,11 +31,12 @@ const meta: Meta = { fieldDefinition: { fieldMetadataId: 'email', label: 'Email', - type: 'EMAIL', + type: FieldMetadataType.Email, iconName: 'IconLink', metadata: { fieldName: 'Email', placeHolder: 'Email', + objectMetadataNameSingular: 'person', }, }, hotkeyScope: 'hotkey-scope', diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/NumberFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/NumberFieldDisplay.stories.tsx index a7f0304a97ce..db7fe3c575f8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/NumberFieldDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/NumberFieldDisplay.stories.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { Meta, StoryObj } from '@storybook/react'; +import { FieldMetadataType } from '~/generated/graphql'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { FieldContext } from '../../../../contexts/FieldContext'; @@ -28,12 +29,13 @@ const meta: Meta = { fieldDefinition: { fieldMetadataId: 'number', label: 'Number', - type: 'NUMBER', + type: FieldMetadataType.Number, iconName: 'Icon123', metadata: { fieldName: 'Number', placeHolder: 'Number', isPositive: true, + objectMetadataNameSingular: 'person', }, }, hotkeyScope: 'hotkey-scope', diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/PhoneFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/PhoneFieldDisplay.stories.tsx index 1425b23afd02..9e9c85f8d340 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/PhoneFieldDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/PhoneFieldDisplay.stories.tsx @@ -3,6 +3,7 @@ import { Meta, StoryObj } from '@storybook/react'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { usePhoneField } from '@/object-record/record-field/meta-types/hooks/usePhoneField'; +import { FieldMetadataType } from '~/generated/graphql'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { MemoryRouterDecorator } from '~/testing/decorators/MemoryRouterDecorator'; @@ -30,7 +31,7 @@ const meta: Meta = { fieldDefinition: { fieldMetadataId: 'phone', label: 'Phone', - type: 'TEXT', + type: FieldMetadataType.Text, iconName: 'IconPhone', metadata: { fieldName: 'phone', diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/TextFieldDisplay.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/TextFieldDisplay.stories.tsx index 76328ea7316e..328a6533c297 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/TextFieldDisplay.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/__stories__/TextFieldDisplay.stories.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; import { Meta, StoryObj } from '@storybook/react'; +import { FieldMetadataType } from '~/generated/graphql'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { FieldContext } from '../../../../contexts/FieldContext'; @@ -28,11 +29,12 @@ const meta: Meta = { fieldDefinition: { fieldMetadataId: 'text', label: 'Text', - type: 'TEXT', + type: FieldMetadataType.Text, iconName: 'IconLink', metadata: { fieldName: 'Text', placeHolder: 'Text', + objectMetadataNameSingular: 'person', }, }, hotkeyScope: 'hotkey-scope', diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts new file mode 100644 index 000000000000..152d6c2ef4d1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useAddressField.ts @@ -0,0 +1,57 @@ +import { useContext } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContext } from '../../contexts/FieldContext'; +import { usePersistField } from '../../hooks/usePersistField'; +import { FieldAddressValue } from '../../types/FieldMetadata'; +import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; +import { isFieldAddress } from '../../types/guards/isFieldAddress'; + +export const useAddressField = () => { + const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.Address, + isFieldAddress, + fieldDefinition, + ); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId: entityId, + fieldName: fieldName, + }), + ); + + const persistField = usePersistField(); + + const persistAddressField = (newValue: FieldAddressValue) => { + if (!isFieldAddressValue(newValue)) { + return; + } + + persistField(newValue); + }; + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${entityId}-${fieldName}`); + + const draftValue = useRecoilValue(getDraftValueSelector()); + + return { + fieldDefinition, + fieldValue, + setFieldValue, + draftValue, + setDraftValue, + hotkeyScope, + persistAddressField, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useBooleanField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useBooleanField.ts index 984ff9f929b8..97284c649861 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useBooleanField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useBooleanField.ts @@ -2,6 +2,7 @@ import { useContext } from 'react'; import { useRecoilState } from 'recoil'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; @@ -10,7 +11,11 @@ import { isFieldBoolean } from '../../types/guards/isFieldBoolean'; export const useBooleanField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('BOOLEAN', isFieldBoolean, fieldDefinition); + assertFieldMetadata( + FieldMetadataType.Boolean, + isFieldBoolean, + fieldDefinition, + ); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts index 998904de6144..c92ba97cdcb4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useCurrencyField.ts @@ -3,6 +3,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { canBeCastAsIntegerOrNull } from '~/utils/cast-as-integer-or-null'; import { convertCurrencyToCurrencyMicros } from '~/utils/convert-currency-amount'; @@ -16,7 +17,11 @@ import { isFieldCurrencyValue } from '../../types/guards/isFieldCurrencyValue'; export const useCurrencyField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('CURRENCY', isFieldCurrency, fieldDefinition); + assertFieldMetadata( + FieldMetadataType.Currency, + isFieldCurrency, + fieldDefinition, + ); const fieldName = fieldDefinition.metadata.fieldName; @@ -59,6 +64,8 @@ export const useCurrencyField = () => { const draftValue = useRecoilValue(getDraftValueSelector()); + const defaultValue = fieldDefinition.defaultValue; + return { fieldDefinition, fieldValue, @@ -67,5 +74,6 @@ export const useCurrencyField = () => { setFieldValue, hotkeyScope, persistCurrencyField, + defaultValue, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateField.ts new file mode 100644 index 000000000000..5505df67e091 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateField.ts @@ -0,0 +1,40 @@ +import { useContext } from 'react'; +import { useRecoilState } from 'recoil'; + +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { FieldDateValue } from '@/object-record/record-field/types/FieldMetadata'; +import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContext } from '../../contexts/FieldContext'; +import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; + +export const useDateField = () => { + const { entityId, fieldDefinition, hotkeyScope, clearable } = + useContext(FieldContext); + + assertFieldMetadata(FieldMetadataType.Date, isFieldDate, fieldDefinition); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId: entityId, + fieldName: fieldName, + }), + ); + + const { setDraftValue } = useRecordFieldInput( + `${entityId}-${fieldName}`, + ); + + return { + fieldDefinition, + fieldValue, + setDraftValue, + setFieldValue, + hotkeyScope, + clearable, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateTimeField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateTimeField.ts index ebc85c116d9b..3043784fc325 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateTimeField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useDateTimeField.ts @@ -4,6 +4,7 @@ import { useRecoilState } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldDateTimeValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; @@ -13,7 +14,11 @@ export const useDateTimeField = () => { const { entityId, fieldDefinition, hotkeyScope, clearable } = useContext(FieldContext); - assertFieldMetadata('DATE_TIME', isFieldDateTime, fieldDefinition); + assertFieldMetadata( + FieldMetadataType.DateTime, + isFieldDateTime, + fieldDefinition, + ); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailField.ts index 4b0b6dd2c615..ba9ad1f8f66c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useEmailField.ts @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldEmailValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; @@ -12,7 +13,7 @@ import { isFieldEmail } from '../../types/guards/isFieldEmail'; export const useEmailField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('EMAIL', isFieldEmail, fieldDefinition); + assertFieldMetadata(FieldMetadataType.Email, isFieldEmail, fieldDefinition); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts index 77a0c0b394fc..553fdde86cca 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useFullNameField.ts @@ -3,6 +3,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { usePersistField } from '../../hooks/usePersistField'; @@ -14,7 +15,11 @@ import { isFieldFullNameValue } from '../../types/guards/isFieldFullNameValue'; export const useFullNameField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('FULL_NAME', isFieldFullName, fieldDefinition); + assertFieldMetadata( + FieldMetadataType.FullName, + isFieldFullName, + fieldDefinition, + ); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts new file mode 100644 index 000000000000..0219eb17398b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useJsonField.ts @@ -0,0 +1,48 @@ +import { useContext } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; +import { FieldJsonValue } from '@/object-record/record-field/types/FieldMetadata'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContext } from '../../contexts/FieldContext'; +import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; +import { isFieldRawJson } from '../../types/guards/isFieldRawJson'; +import { isFieldTextValue } from '../../types/guards/isFieldTextValue'; + +export const useJsonField = () => { + const { entityId, fieldDefinition, hotkeyScope, maxWidth } = + useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.RawJson, + isFieldRawJson, + fieldDefinition, + ); + + const fieldName = fieldDefinition.metadata.fieldName; + + const [fieldValue, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId: entityId, + fieldName: fieldName, + }), + ); + const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : ''; + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${entityId}-${fieldName}`); + + const draftValue = useRecoilValue(getDraftValueSelector()); + + return { + draftValue, + setDraftValue, + maxWidth, + fieldDefinition, + fieldValue: fieldTextValue, + setFieldValue, + hotkeyScope, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinkField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinkField.ts index 0af2a0d105f5..7a422f72bd14 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinkField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useLinkField.ts @@ -3,6 +3,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { usePersistField } from '../../hooks/usePersistField'; @@ -14,7 +15,7 @@ import { isFieldLinkValue } from '../../types/guards/isFieldLinkValue'; export const useLinkField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('LINK', isFieldLink, fieldDefinition); + assertFieldMetadata(FieldMetadataType.Link, isFieldLink, fieldDefinition); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts new file mode 100644 index 000000000000..5a4a2eacd4f4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useMultiSelectField.ts @@ -0,0 +1,50 @@ +import { useContext } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext.ts'; +import { usePersistField } from '@/object-record/record-field/hooks/usePersistField.ts'; +import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput.ts'; +import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts'; +import { assertFieldMetadata } from '@/object-record/record-field/types/guards/assertFieldMetadata.ts'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; +import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts'; +import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector.ts'; +import { FieldMetadataType } from '~/generated/graphql.tsx'; + +export const useMultiSelectField = () => { + const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); + + assertFieldMetadata( + FieldMetadataType.MultiSelect, + isFieldMultiSelect, + fieldDefinition, + ); + + const { fieldName } = fieldDefinition.metadata; + + const [fieldValues, setFieldValue] = useRecoilState( + recordStoreFamilySelector({ + recordId: entityId, + fieldName: fieldName, + }), + ); + + const fieldMultiSelectValues = isFieldMultiSelectValue(fieldValues) + ? fieldValues + : null; + const persistField = usePersistField(); + + const { setDraftValue, getDraftValueSelector } = + useRecordFieldInput(`${entityId}-${fieldName}`); + const draftValue = useRecoilValue(getDraftValueSelector()); + + return { + fieldDefinition, + persistField, + fieldValues: fieldMultiSelectValues, + draftValue, + setDraftValue, + setFieldValue, + hotkeyScope, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts index 1ddaf7385f15..9ae483bde854 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useNumberField.ts @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldNumberValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { canBeCastAsIntegerOrNull, castAsIntegerOrNull, @@ -17,7 +18,7 @@ import { isFieldNumber } from '../../types/guards/isFieldNumber'; export const useNumberField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('NUMBER', isFieldNumber, fieldDefinition); + assertFieldMetadata(FieldMetadataType.Number, isFieldNumber, fieldDefinition); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts index 5e343d1f8d60..3f8891214d57 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/usePhoneField.ts @@ -5,6 +5,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldPhoneValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { usePersistField } from '../../hooks/usePersistField'; @@ -14,7 +15,7 @@ import { isFieldPhone } from '../../types/guards/isFieldPhone'; export const usePhoneField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('TEXT', isFieldPhone, fieldDefinition); + assertFieldMetadata(FieldMetadataType.Text, isFieldPhone, fieldDefinition); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationField.ts index bd8355b80dc5..f61b22df666d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationField.ts @@ -5,6 +5,7 @@ import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButto import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldRelationValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; @@ -14,7 +15,12 @@ import { isFieldRelation } from '../../types/guards/isFieldRelation'; export const useRelationField = () => { const { entityId, fieldDefinition, maxWidth } = useContext(FieldContext); const button = useGetButtonIcon(); - assertFieldMetadata('RELATION', isFieldRelation, fieldDefinition); + + assertFieldMetadata( + FieldMetadataType.Relation, + isFieldRelation, + fieldDefinition, + ); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useTextField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useTextField.ts index c3222dda7176..6d3f36aacd52 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useTextField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useTextField.ts @@ -4,6 +4,7 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { FieldTextValue } from '@/object-record/record-field/types/FieldMetadata'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; @@ -14,7 +15,7 @@ export const useTextField = () => { const { entityId, fieldDefinition, hotkeyScope, maxWidth } = useContext(FieldContext); - assertFieldMetadata('TEXT', isFieldText, fieldDefinition); + assertFieldMetadata(FieldMetadataType.Text, isFieldText, fieldDefinition); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useUuidField.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useUuidField.ts index 8e258ff098db..8a3224c50f65 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useUuidField.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useUuidField.ts @@ -4,6 +4,7 @@ import { useRecoilState } from 'recoil'; import { FieldUUidValue } from '@/object-record/record-field/types/FieldMetadata'; import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldContext } from '../../contexts/FieldContext'; import { assertFieldMetadata } from '../../types/guards/assertFieldMetadata'; @@ -12,7 +13,7 @@ import { isFieldTextValue } from '../../types/guards/isFieldTextValue'; export const useUuidField = () => { const { entityId, fieldDefinition, hotkeyScope } = useContext(FieldContext); - assertFieldMetadata('UUID', isFieldUuid, fieldDefinition); + assertFieldMetadata(FieldMetadataType.Uuid, isFieldUuid, fieldDefinition); const fieldName = fieldDefinition.metadata.fieldName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx new file mode 100644 index 000000000000..952a5e35665f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/AddressFieldInput.tsx @@ -0,0 +1,85 @@ +import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; +import { AddressInput } from '@/ui/field/input/components/AddressInput'; +import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay'; + +import { usePersistField } from '../../../hooks/usePersistField'; + +import { FieldInputEvent } from './DateFieldInput'; + +export type AddressFieldInputProps = { + onClickOutside?: FieldInputEvent; + onEnter?: FieldInputEvent; + onEscape?: FieldInputEvent; + onTab?: FieldInputEvent; + onShiftTab?: FieldInputEvent; +}; + +export const AddressFieldInput = ({ + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, +}: AddressFieldInputProps) => { + const { hotkeyScope, draftValue, setDraftValue } = useAddressField(); + + const persistField = usePersistField(); + + const convertToAddress = ( + newAddress: FieldAddressDraftValue | undefined, + ): FieldAddressDraftValue => { + return { + addressStreet1: newAddress?.addressStreet1 ?? '', + addressStreet2: newAddress?.addressStreet2 ?? null, + addressCity: newAddress?.addressCity ?? null, + addressState: newAddress?.addressState ?? null, + addressCountry: newAddress?.addressCountry ?? null, + addressPostcode: newAddress?.addressPostcode ?? null, + addressLat: newAddress?.addressLat ?? null, + addressLng: newAddress?.addressLng ?? null, + }; + }; + + const handleEnter = (newAddress: FieldAddressDraftValue) => { + onEnter?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleTab = (newAddress: FieldAddressDraftValue) => { + onTab?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleShiftTab = (newAddress: FieldAddressDraftValue) => { + onShiftTab?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleEscape = (newAddress: FieldAddressDraftValue) => { + onEscape?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleClickOutside = ( + event: MouseEvent | TouchEvent, + newAddress: FieldAddressDraftValue, + ) => { + onClickOutside?.(() => persistField(convertToAddress(newAddress))); + }; + + const handleChange = (newAddress: FieldAddressDraftValue) => { + setDraftValue(convertToAddress(newAddress)); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx index f6cffa3b80d6..b2b5cb97d553 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/BooleanFieldInput.tsx @@ -3,7 +3,7 @@ import { BooleanInput } from '@/ui/field/input/components/BooleanInput'; import { usePersistField } from '../../../hooks/usePersistField'; import { useBooleanField } from '../../hooks/useBooleanField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type BooleanFieldInputProps = { onSubmit?: FieldInputEvent; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx index 5ab4617eddb8..08ebc0601596 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/CurrencyFieldInput.tsx @@ -1,10 +1,11 @@ import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; -import { TextInput } from '@/ui/field/input/components/TextInput'; +import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMetadata'; +import { CurrencyInput } from '@/ui/field/input/components/CurrencyInput'; import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay'; import { useCurrencyField } from '../../hooks/useCurrencyField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type CurrencyFieldInputProps = { onClickOutside?: FieldInputEvent; @@ -21,14 +22,27 @@ export const CurrencyFieldInput = ({ onTab, onShiftTab, }: CurrencyFieldInputProps) => { - const { hotkeyScope, draftValue, persistCurrencyField, setDraftValue } = - useCurrencyField(); + const { + hotkeyScope, + draftValue, + persistCurrencyField, + setDraftValue, + defaultValue, + } = useCurrencyField(); + + const currencyCode = + draftValue?.currencyCode ?? + ((defaultValue as FieldCurrencyValue).currencyCode.replace( + /'/g, + '', + ) as CurrencyCode) ?? + CurrencyCode.USD; const handleEnter = (newValue: string) => { onEnter?.(() => { persistCurrencyField({ amountText: newValue, - currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD, + currencyCode, }); }); }; @@ -37,7 +51,7 @@ export const CurrencyFieldInput = ({ onEscape?.(() => { persistCurrencyField({ amountText: newValue, - currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD, + currencyCode, }); }); }; @@ -49,7 +63,7 @@ export const CurrencyFieldInput = ({ onClickOutside?.(() => { persistCurrencyField({ amountText: newValue, - currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD, + currencyCode, }); }); }; @@ -58,7 +72,7 @@ export const CurrencyFieldInput = ({ onTab?.(() => { persistCurrencyField({ amountText: newValue, - currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD, + currencyCode, }); }); }; @@ -67,7 +81,7 @@ export const CurrencyFieldInput = ({ onShiftTab?.(() => persistCurrencyField({ amountText: newValue, - currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD, + currencyCode, }), ); }; @@ -75,14 +89,22 @@ export const CurrencyFieldInput = ({ const handleChange = (newValue: string) => { setDraftValue({ amount: newValue, - currencyCode: draftValue?.currencyCode ?? CurrencyCode.USD, + currencyCode, + }); + }; + + const handleSelect = (newValue: string) => { + setDraftValue({ + amount: draftValue?.amount ?? '', + currencyCode: newValue as CurrencyCode, }); }; return ( - ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx index c1165f9737c3..631d25624c13 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateFieldInput.tsx @@ -1,8 +1,8 @@ +import { useDateField } from '@/object-record/record-field/meta-types/hooks/useDateField'; import { DateInput } from '@/ui/field/input/components/DateInput'; import { Nullable } from '~/types/Nullable'; import { usePersistField } from '../../../hooks/usePersistField'; -import { useDateTimeField } from '../../hooks/useDateTimeField'; export type FieldInputEvent = (persist: () => void) => void; @@ -17,8 +17,7 @@ export const DateFieldInput = ({ onEscape, onClickOutside, }: DateFieldInputProps) => { - const { fieldValue, hotkeyScope, clearable, setDraftValue } = - useDateTimeField(); + const { fieldValue, hotkeyScope, setDraftValue } = useDateField(); const persistField = usePersistField(); @@ -60,7 +59,7 @@ export const DateFieldInput = ({ onEnter={handleEnter} onEscape={handleEscape} value={dateValue} - clearable={clearable} + clearable onChange={handleChange} /> ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx new file mode 100644 index 000000000000..d3b5f46ce0e9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/DateTimeFieldInput.tsx @@ -0,0 +1,68 @@ +import { DateInput } from '@/ui/field/input/components/DateInput'; +import { Nullable } from '~/types/Nullable'; + +import { usePersistField } from '../../../hooks/usePersistField'; +import { useDateTimeField } from '../../hooks/useDateTimeField'; + +export type FieldInputEvent = (persist: () => void) => void; + +export type DateTimeFieldInputProps = { + onClickOutside?: FieldInputEvent; + onEnter?: FieldInputEvent; + onEscape?: FieldInputEvent; +}; + +export const DateTimeFieldInput = ({ + onEnter, + onEscape, + onClickOutside, +}: DateTimeFieldInputProps) => { + const { fieldValue, hotkeyScope, clearable, setDraftValue } = + useDateTimeField(); + + const persistField = usePersistField(); + + const persistDate = (newDate: Nullable) => { + if (!newDate) { + persistField(null); + } else { + const newDateISO = newDate?.toISOString(); + + persistField(newDateISO); + } + }; + + const handleEnter = (newDate: Nullable) => { + onEnter?.(() => persistDate(newDate)); + }; + + const handleEscape = (newDate: Nullable) => { + onEscape?.(() => persistDate(newDate)); + }; + + const handleClickOutside = ( + _event: MouseEvent | TouchEvent, + newDate: Nullable, + ) => { + onClickOutside?.(() => persistDate(newDate)); + }; + + const handleChange = (newDate: Nullable) => { + setDraftValue(newDate?.toDateString() ?? ''); + }; + + const dateValue = fieldValue ? new Date(fieldValue) : null; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailFieldInput.tsx index 4d6bb38623a1..29ed4b2f15c6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/EmailFieldInput.tsx @@ -4,7 +4,7 @@ import { FieldInputOverlay } from '../../../../../ui/field/input/components/Fiel import { usePersistField } from '../../../hooks/usePersistField'; import { useEmailField } from '../../hooks/useEmailField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type EmailFieldInputProps = { onClickOutside?: FieldInputEvent; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx index 77fa108e15ed..5b6e06a64ff7 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/FullNameFieldInput.tsx @@ -5,7 +5,7 @@ import { FieldInputOverlay } from '@/ui/field/input/components/FieldInputOverlay import { usePersistField } from '../../../hooks/usePersistField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; const FIRST_NAME_PLACEHOLDER_WITH_SPECIAL_CHARACTER_TO_AVOID_PASSWORD_MANAGERS = 'F‌‌irst name'; @@ -66,6 +66,10 @@ export const FullNameFieldInput = ({ setDraftValue(convertToFullName(newDoubleText)); }; + const handlePaste = (newDoubleText: FieldDoubleText) => { + setDraftValue(convertToFullName(newDoubleText)); + }; + return ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx index 255dfb0864a5..5bf5571f1b56 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/LinkFieldInput.tsx @@ -3,7 +3,7 @@ import { TextInput } from '@/ui/field/input/components/TextInput'; import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay'; import { useLinkField } from '../../hooks/useLinkField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type LinkFieldInputProps = { onClickOutside?: FieldInputEvent; 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 new file mode 100644 index 000000000000..864384918d99 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx @@ -0,0 +1,93 @@ +import { useRef, useState } from 'react'; +import styled from '@emotion/styled'; + +import { useMultiSelectField } from '@/object-record/record-field/meta-types/hooks/useMultiSelectField.ts'; +import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; +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 { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag.tsx'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { isDefined } from '~/utils/isDefined'; + +const StyledRelationPickerContainer = styled.div` + left: -1px; + position: absolute; + top: -1px; +`; + +export type MultiSelectFieldInputProps = { + onSubmit?: FieldInputEvent; + onCancel?: () => void; +}; + +export const MultiSelectFieldInput = ({ + onCancel, +}: MultiSelectFieldInputProps) => { + const { persistField, fieldDefinition, fieldValues } = useMultiSelectField(); + const [searchFilter, setSearchFilter] = useState(''); + const containerRef = useRef(null); + + const selectedOptions = fieldDefinition.metadata.options.filter( + (option) => fieldValues?.includes(option.value), + ); + + const optionsInDropDown = fieldDefinition.metadata.options; + + const formatNewSelectedOptions = (value: string) => { + const selectedOptionsValues = selectedOptions.map( + (selectedOption) => selectedOption.value, + ); + if (!selectedOptionsValues.includes(value)) { + return [value, ...selectedOptionsValues]; + } else { + return selectedOptionsValues.filter( + (selectedOptionsValue) => selectedOptionsValue !== value, + ); + } + }; + + useListenClickOutside({ + refs: [containerRef], + callback: (event) => { + event.stopImmediatePropagation(); + + const weAreNotInAnHTMLInput = !( + event.target instanceof HTMLInputElement && + event.target.tagName === 'INPUT' + ); + if (weAreNotInAnHTMLInput && isDefined(onCancel)) { + onCancel(); + } + }, + }); + + return ( + + + setSearchFilter(event.currentTarget.value)} + autoFocus + /> + + + {optionsInDropDown.map((option) => { + return ( + + persistField(formatNewSelectedOptions(option.value)) + } + /> + ); + })} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhoneFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhoneFieldInput.tsx index 3e6e7f8943e2..179455da6a0e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhoneFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/PhoneFieldInput.tsx @@ -3,7 +3,7 @@ import { PhoneInput } from '@/ui/field/input/components/PhoneInput'; import { FieldInputOverlay } from '../../../../../ui/field/input/components/FieldInputOverlay'; import { usePhoneField } from '../../hooks/usePhoneField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type PhoneFieldInputProps = { onClickOutside?: FieldInputEvent; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx index 447be4f06318..873708669e70 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RatingFieldInput.tsx @@ -4,7 +4,7 @@ import { RatingInput } from '@/ui/field/input/components/RatingInput'; import { usePersistField } from '../../../hooks/usePersistField'; import { useRatingField } from '../../hooks/useRatingField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type RatingFieldInputProps = { onSubmit?: FieldInputEvent; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx new file mode 100644 index 000000000000..34a06e814144 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RawJsonFieldInput.tsx @@ -0,0 +1,83 @@ +import { isValidJSON } from '@/object-record/record-field/utils/isFieldValueJson'; +import { FieldTextAreaOverlay } from '@/ui/field/input/components/FieldTextAreaOverlay'; +import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput'; + +import { usePersistField } from '../../../hooks/usePersistField'; +import { useJsonField } from '../../hooks/useJsonField'; + +import { FieldInputEvent } from './DateFieldInput'; + +export type RawJsonFieldInputProps = { + onClickOutside?: FieldInputEvent; + onEnter?: FieldInputEvent; + onEscape?: FieldInputEvent; + onTab?: FieldInputEvent; + onShiftTab?: FieldInputEvent; +}; + +export const RawJsonFieldInput = ({ + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, +}: RawJsonFieldInputProps) => { + const { fieldDefinition, draftValue, hotkeyScope, setDraftValue } = + useJsonField(); + + const persistField = usePersistField(); + + const handlePersistField = (newText: string) => { + if (!newText || isValidJSON(newText)) persistField(newText || null); + }; + + const handleEnter = (newText: string) => { + onEnter?.(() => handlePersistField(newText)); + }; + + const handleEscape = (newText: string) => { + onEscape?.(() => handlePersistField(newText)); + }; + + const handleClickOutside = ( + _event: MouseEvent | TouchEvent, + newText: string, + ) => { + onClickOutside?.(() => handlePersistField(newText)); + }; + + const handleTab = (newText: string) => { + onTab?.(() => handlePersistField(newText)); + }; + + const handleShiftTab = (newText: string) => { + onShiftTab?.(() => handlePersistField(newText)); + }; + + const handleChange = (newText: string) => { + setDraftValue(newText); + }; + + const value = + draftValue && isValidJSON(draftValue) + ? JSON.stringify(JSON.parse(draftValue), null, 2) + : draftValue ?? ''; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx index 17d3e036571a..ec80fd26c1df 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFieldInput.tsx @@ -6,7 +6,7 @@ import { EntityForSelect } from '@/object-record/relation-picker/types/EntityFor import { usePersistField } from '../../../hooks/usePersistField'; import { useRelationField } from '../../hooks/useRelationField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; const StyledRelationPickerContainer = styled.div` left: -1px; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx index 4c730f4290b8..e0b433eef715 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/SelectFieldInput.tsx @@ -1,10 +1,15 @@ +import { useRef, useState } from 'react'; import styled from '@emotion/styled'; -import { MenuItem } from 'tsup.ui.index'; import { useSelectField } from '@/object-record/record-field/meta-types/hooks/useSelectField'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; 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 { MenuItemSelectTag } from '@/ui/navigation/menu-item/components/MenuItemSelectTag'; +import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { isDefined } from '~/utils/isDefined'; const StyledRelationPickerContainer = styled.div` left: -1px; @@ -14,19 +19,63 @@ const StyledRelationPickerContainer = styled.div` export type SelectFieldInputProps = { onSubmit?: FieldInputEvent; + onCancel?: () => void; }; -export const SelectFieldInput = ({ onSubmit }: SelectFieldInputProps) => { - const { persistField, fieldDefinition } = useSelectField(); +export const SelectFieldInput = ({ + onSubmit, + onCancel, +}: SelectFieldInputProps) => { + const { persistField, fieldDefinition, fieldValue } = useSelectField(); + const [searchFilter, setSearchFilter] = useState(''); + const containerRef = useRef(null); + + const selectedOption = fieldDefinition.metadata.options.find( + (option) => option.value === fieldValue, + ); + const optionsToSelect = + fieldDefinition.metadata.options.filter((option) => { + return ( + option.value !== fieldValue && + option.label.toLowerCase().includes(searchFilter.toLowerCase()) + ); + }) || []; + const optionsInDropDown = selectedOption + ? [selectedOption, ...optionsToSelect] + : optionsToSelect; + + useListenClickOutside({ + refs: [containerRef], + callback: (event) => { + event.stopImmediatePropagation(); + + const weAreNotInAnHTMLInput = !( + event.target instanceof HTMLInputElement && + event.target.tagName === 'INPUT' + ); + if (weAreNotInAnHTMLInput && isDefined(onCancel)) { + onCancel(); + } + }, + }); return ( - + - - {fieldDefinition.metadata.options.map((option) => { + setSearchFilter(event.currentTarget.value)} + autoFocus + /> + + + {optionsInDropDown.map((option) => { return ( - onSubmit?.(() => persistField(option.value))} /> ); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx index 1a9eee83ea98..087dc37f48de 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/TextFieldInput.tsx @@ -4,7 +4,7 @@ import { TextAreaInput } from '@/ui/field/input/components/TextAreaInput'; import { usePersistField } from '../../../hooks/usePersistField'; import { useTextField } from '../../hooks/useTextField'; -import { FieldInputEvent } from './DateFieldInput'; +import { FieldInputEvent } from './DateTimeFieldInput'; export type TextFieldInputProps = { onClickOutside?: FieldInputEvent; 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 new file mode 100644 index 000000000000..7241793cb310 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx @@ -0,0 +1,138 @@ +import { useEffect } from 'react'; +import { Decorator, Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, waitFor } from '@storybook/test'; + +import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; +import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; +import { + AddressInput, + AddressInputProps, +} from '@/ui/field/input/components/AddressInput'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; + +const AddressValueSetterEffect = ({ + value, +}: { + value: FieldAddressDraftValue; +}) => { + const { setFieldValue } = useAddressField(); + + useEffect(() => { + setFieldValue(value); + }, [setFieldValue, value]); + + return <>; +}; + +type AddressInputWithContextProps = AddressInputProps & { + value: string; + entityId?: string; +}; + +const AddressInputWithContext = ({ + entityId, + value, + onEnter, + onEscape, + onClickOutside, + onTab, + onShiftTab, +}: AddressInputWithContextProps) => { + const setHotKeyScope = useSetHotkeyScope(); + + useEffect(() => { + setHotKeyScope('hotkey-scope'); + }, [setHotKeyScope]); + + return ( +
+ + + + +
+
+ ); +}; + +const enterJestFn = fn(); +const escapeJestfn = fn(); +const clickOutsideJestFn = fn(); +const tabJestFn = fn(); +const shiftTabJestFn = fn(); + +const clearMocksDecorator: Decorator = (Story, context) => { + if (context.parameters.clearMocks === true) { + enterJestFn.mockClear(); + escapeJestfn.mockClear(); + clickOutsideJestFn.mockClear(); + tabJestFn.mockClear(); + shiftTabJestFn.mockClear(); + } + return ; +}; + +const meta: Meta = { + title: 'UI/Data/Field/Input/AddressFieldInput', + component: AddressInputWithContext, + args: { + value: 'text', + onEnter: enterJestFn, + onEscape: escapeJestfn, + onClickOutside: clickOutsideJestFn, + onTab: tabJestFn, + onShiftTab: shiftTabJestFn, + }, + argTypes: { + onEnter: { control: false }, + onEscape: { control: false }, + onClickOutside: { control: false }, + onTab: { control: false }, + onShiftTab: { control: false }, + }, + decorators: [clearMocksDecorator], + parameters: { + clearMocks: true, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const Enter: Story = { + play: async () => { + expect(enterJestFn).toHaveBeenCalledTimes(0); + + await waitFor(() => { + userEvent.keyboard('{enter}'); + expect(enterJestFn).toHaveBeenCalledTimes(1); + }); + }, +}; 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 7e556614b5f8..934d7b3e4e49 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 @@ -4,6 +4,7 @@ import { expect, fn, userEvent, within } from '@storybook/test'; import { useSetRecoilState } from 'recoil'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { FieldMetadataType } from '~/generated/graphql'; import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { @@ -43,9 +44,10 @@ const BooleanFieldInputWithContext = ({ fieldMetadataId: 'boolean', label: 'Boolean', iconName: 'Icon123', - type: 'BOOLEAN', + type: FieldMetadataType.Boolean, metadata: { fieldName: 'Boolean', + objectMetadataNameSingular: 'person', }, }} entityId={entityId} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateFieldInput.stories.tsx deleted file mode 100644 index cfefb9efeb8d..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateFieldInput.stories.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useEffect } from 'react'; -import { Meta, StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, within } from '@storybook/test'; - -import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; - -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; -import { useDateTimeField } from '../../../hooks/useDateTimeField'; -import { DateFieldInput, DateFieldInputProps } from '../DateFieldInput'; - -const formattedDate = new Date(2022, 1, 1); - -const DateFieldValueSetterEffect = ({ value }: { value: Date }) => { - const { setFieldValue } = useDateTimeField(); - - useEffect(() => { - setFieldValue(value.toISOString()); - }, [setFieldValue, value]); - - return <>; -}; - -type DateFieldInputWithContextProps = DateFieldInputProps & { - value: Date; - entityId?: string; -}; - -const DateFieldInputWithContext = ({ - value, - entityId, - onEscape, - onEnter, - onClickOutside, -}: DateFieldInputWithContextProps) => { - const setHotkeyScope = useSetHotkeyScope(); - - useEffect(() => { - setHotkeyScope('hotkey-scope'); - }, [setHotkeyScope]); - - return ( -
- - - - -
-
- ); -}; - -const escapeJestFn = fn(); -const enterJestFn = fn(); -const clickOutsideJestFn = fn(); - -const meta: Meta = { - title: 'UI/Data/Field/Input/DateFieldInput', - component: DateFieldInputWithContext, - args: { - value: formattedDate, - onEscape: escapeJestFn, - onEnter: enterJestFn, - onClickOutside: clickOutsideJestFn, - }, - argTypes: { - onEscape: { - control: false, - }, - onEnter: { - control: false, - }, - onClickOutside: { - control: false, - }, - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const div = await canvas.findByText('Feb 1, 2022'); - - await expect(div.innerText).toContain('Feb 1, 2022'); - }, -}; - -export const ClickOutside: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - await expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); - - const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); - await userEvent.click(emptyDiv); - - await expect(clickOutsideJestFn).toHaveBeenCalledTimes(1); - }, -}; - -export const Escape: Story = { - play: async () => { - await expect(escapeJestFn).toHaveBeenCalledTimes(0); - - await userEvent.keyboard('{esc}'); - - await expect(escapeJestFn).toHaveBeenCalledTimes(1); - }, -}; - -export const Enter: Story = { - play: async () => { - await expect(enterJestFn).toHaveBeenCalledTimes(0); - - await userEvent.keyboard('{enter}'); - - await expect(enterJestFn).toHaveBeenCalledTimes(1); - }, -}; 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 new file mode 100644 index 000000000000..28bd5948fb68 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx @@ -0,0 +1,142 @@ +import { useEffect } from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from '@storybook/test'; + +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated/graphql'; + +import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { useDateTimeField } from '../../../hooks/useDateTimeField'; +import { + DateTimeFieldInput, + DateTimeFieldInputProps, +} from '../DateTimeFieldInput'; + +const formattedDate = new Date(2022, 1, 1); + +const DateFieldValueSetterEffect = ({ value }: { value: Date }) => { + const { setFieldValue } = useDateTimeField(); + + useEffect(() => { + setFieldValue(value.toISOString()); + }, [setFieldValue, value]); + + return <>; +}; + +type DateFieldInputWithContextProps = DateTimeFieldInputProps & { + value: Date; + entityId?: string; +}; + +const DateFieldInputWithContext = ({ + value, + entityId, + onEscape, + onEnter, + onClickOutside, +}: DateFieldInputWithContextProps) => { + const setHotkeyScope = useSetHotkeyScope(); + + useEffect(() => { + setHotkeyScope('hotkey-scope'); + }, [setHotkeyScope]); + + return ( +
+ + + + +
+
+ ); +}; + +const escapeJestFn = fn(); +const enterJestFn = fn(); +const clickOutsideJestFn = fn(); + +const meta: Meta = { + title: 'UI/Data/Field/Input/DateFieldInput', + component: DateFieldInputWithContext, + args: { + value: formattedDate, + onEscape: escapeJestFn, + onEnter: enterJestFn, + onClickOutside: clickOutsideJestFn, + }, + argTypes: { + onEscape: { + control: false, + }, + onEnter: { + control: false, + }, + onClickOutside: { + control: false, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const div = await canvas.findByText('Feb 1, 2022'); + + await expect(div.innerText).toContain('Feb 1, 2022'); + }, +}; + +export const ClickOutside: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + await expect(clickOutsideJestFn).toHaveBeenCalledTimes(0); + + const emptyDiv = canvas.getByTestId('data-field-input-click-outside-div'); + await userEvent.click(emptyDiv); + + await expect(clickOutsideJestFn).toHaveBeenCalledTimes(1); + }, +}; + +export const Escape: Story = { + play: async () => { + await expect(escapeJestFn).toHaveBeenCalledTimes(0); + + await userEvent.keyboard('{esc}'); + + await expect(escapeJestFn).toHaveBeenCalledTimes(1); + }, +}; + +export const Enter: Story = { + play: async () => { + await expect(enterJestFn).toHaveBeenCalledTimes(0); + + await userEvent.keyboard('{enter}'); + + await expect(enterJestFn).toHaveBeenCalledTimes(1); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailFieldInput.stories.tsx index 712e2c46be73..b96085ee8dd5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/EmailFieldInput.stories.tsx @@ -3,6 +3,7 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated/graphql'; import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { useEmailField } from '../../../hooks/useEmailField'; @@ -44,11 +45,12 @@ const EmailFieldInputWithContext = ({ fieldDefinition={{ fieldMetadataId: 'email', label: 'Email', - type: 'EMAIL', + type: FieldMetadataType.Email, iconName: 'IconLink', metadata: { fieldName: 'email', placeHolder: 'username@email.com', + objectMetadataNameSingular: 'person', }, }} entityId={entityId} @@ -74,7 +76,7 @@ const tabJestFn = fn(); const shiftTabJestFn = fn(); const clearMocksDecorator: Decorator = (Story, context) => { - if (context.parameters.clearMocks) { + if (context.parameters.clearMocks === true) { enterJestFn.mockClear(); escapeJestfn.mockClear(); clickOutsideJestFn.mockClear(); 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 ace77e5915de..832797657de1 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 @@ -3,6 +3,7 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated/graphql'; import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { useNumberField } from '../../../hooks/useNumberField'; @@ -45,10 +46,11 @@ const NumberFieldInputWithContext = ({ fieldMetadataId: 'number', label: 'Number', iconName: 'Icon123', - type: 'NUMBER', + type: FieldMetadataType.Number, metadata: { fieldName: 'number', placeHolder: 'Enter number', + objectMetadataNameSingular: 'person', }, }} entityId={entityId} @@ -74,7 +76,7 @@ const tabJestFn = fn(); const shiftTabJestFn = fn(); const clearMocksDecorator: Decorator = (Story, context) => { - if (context.parameters.clearMocks) { + if (context.parameters.clearMocks === true) { enterJestFn.mockClear(); escapeJestfn.mockClear(); clickOutsideJestFn.mockClear(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhoneFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhoneFieldInput.stories.tsx index 5245859abe2e..c38b58a2d687 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhoneFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/PhoneFieldInput.stories.tsx @@ -3,6 +3,7 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated/graphql'; import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { usePhoneField } from '../../../hooks/usePhoneField'; @@ -44,7 +45,7 @@ const PhoneFieldInputWithContext = ({ fieldDefinition={{ fieldMetadataId: 'phone', label: 'Phone', - type: 'TEXT', + type: FieldMetadataType.Text, iconName: 'IconPhone', metadata: { fieldName: 'phone', @@ -75,7 +76,7 @@ const tabJestFn = fn(); const shiftTabJestFn = fn(); const clearMocksDecorator: Decorator = (Story, context) => { - if (context.parameters.clearMocks) { + if (context.parameters.clearMocks === true) { enterJestFn.mockClear(); escapeJestfn.mockClear(); clickOutsideJestFn.mockClear(); 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 7d148460d394..2f6217150c31 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 @@ -4,6 +4,7 @@ import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; import { FieldRatingValue } from '../../../../types/FieldMetadata'; import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; @@ -49,6 +50,7 @@ const RatingFieldInputWithContext = ({ iconName: 'Icon123', metadata: { fieldName: 'Rating', + objectMetadataNameSingular: 'person', }, }} entityId={entityId} @@ -62,7 +64,7 @@ const RatingFieldInputWithContext = ({ const submitJestFn = fn(); const clearMocksDecorator: Decorator = (Story, context) => { - if (context.parameters.clearMocks) { + if (context.parameters.clearMocks === true) { submitJestFn.mockClear(); } return ; @@ -100,7 +102,7 @@ export const Submit: Story = { const firstStar = input.firstElementChild; await waitFor(() => { - if (firstStar) { + if (isDefined(firstStar)) { userEvent.click(firstStar); expect(submitJestFn).toHaveBeenCalledTimes(1); } diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx index 769074690278..a6fca0e4d1fc 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationFieldInput.stories.tsx @@ -14,6 +14,7 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated/graphql'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; @@ -65,13 +66,14 @@ const RelationFieldInputWithContext = ({ fieldDefinition={{ fieldMetadataId: 'relation', label: 'Relation', - type: 'RELATION', + type: FieldMetadataType.Relation, iconName: 'IconLink', metadata: { fieldName: 'Relation', relationObjectMetadataNamePlural: 'workspaceMembers', relationObjectMetadataNameSingular: CoreObjectNameSingular.WorkspaceMember, + objectMetadataNameSingular: 'person', }, }} entityId={entityId} @@ -88,7 +90,7 @@ const submitJestFn = fn(); const cancelJestFn = fn(); const clearMocksDecorator: Decorator = (Story, context) => { - if (context.parameters.clearMocks) { + if (context.parameters.clearMocks === true) { submitJestFn.mockClear(); cancelJestFn.mockClear(); } 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 c89955dea65c..09d400028b6e 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 @@ -3,6 +3,7 @@ import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { FieldMetadataType } from '~/generated/graphql'; import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { useTextField } from '../../../hooks/useTextField'; @@ -44,11 +45,12 @@ const TextFieldInputWithContext = ({ fieldDefinition={{ fieldMetadataId: 'text', label: 'Text', - type: 'TEXT', + type: FieldMetadataType.Text, iconName: 'IconTag', metadata: { fieldName: 'Text', placeHolder: 'Enter text', + objectMetadataNameSingular: 'person', }, }} entityId={entityId} @@ -74,7 +76,7 @@ const tabJestFn = fn(); const shiftTabJestFn = fn(); const clearMocksDecorator: Decorator = (Story, context) => { - if (context.parameters.clearMocks) { + if (context.parameters.clearMocks === true) { enterJestFn.mockClear(); escapeJestfn.mockClear(); clickOutsideJestFn.mockClear(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts index 80343923ac42..c4aa0b308519 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents.ts @@ -2,7 +2,7 @@ import { Key } from 'ts-key-enum'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useRegisterInputEvents = ({ inputRef, @@ -30,7 +30,7 @@ export const useRegisterInputEvents = ({ onClickOutside?.(event, inputValue); }, - enabled: isNonNullable(onClickOutside), + enabled: isDefined(onClickOutside), }); useScopedHotkeys( diff --git a/packages/twenty-front/src/modules/object-record/record-field/scopes/scope-internal-context/RecordFieldInputScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/record-field/scopes/scope-internal-context/RecordFieldInputScopeInternalContext.ts index 240470e528ef..eebf6c22d583 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/scopes/scope-internal-context/RecordFieldInputScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/scopes/scope-internal-context/RecordFieldInputScopeInternalContext.ts @@ -1,7 +1,7 @@ -import { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; +import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; -type RecordFieldInputScopeInternalContextProps = StateScopeMapKey; +type RecordFieldInputScopeInternalContextProps = ComponentStateKey; export const RecordFieldInputScopeInternalContext = createScopeInternalContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/recordFieldInputDraftValueComponentState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/recordFieldInputDraftValueComponentState.ts new file mode 100644 index 000000000000..ccc67af80746 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/recordFieldInputDraftValueComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordFieldInputDraftValueComponentState = + createComponentState({ + key: 'recordFieldInputDraftValueComponentState', + defaultValue: undefined, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/recordFieldInputDraftValueStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-field/states/recordFieldInputDraftValueStateScopeMap.ts deleted file mode 100644 index 1d0720c43099..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/states/recordFieldInputDraftValueStateScopeMap.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const recordFieldInputDraftValueStateScopeMap = createStateScopeMap( - { - key: 'recordFieldInputDraftValueStateScopeMap', - defaultValue: undefined, - }, -); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector.ts new file mode 100644 index 000000000000..e96dd4075610 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/selectors/recordFieldInputDraftValueComponentSelector.ts @@ -0,0 +1,16 @@ +import { recordFieldInputDraftValueComponentState } from '@/object-record/record-field/states/recordFieldInputDraftValueComponentState'; +import { createComponentSelector } from '@/ui/utilities/state/component-state/utils/createComponentSelector'; + +export const recordFieldInputDraftValueComponentSelector = + createComponentSelector({ + key: 'recordFieldInputDraftValueComponentSelector', + get: + ({ scopeId }: { scopeId: string }) => + ({ get }) => + get(recordFieldInputDraftValueComponentState({ scopeId })) as T, + set: + ({ scopeId }: { scopeId: string }) => + ({ set }, newValue: T) => { + set(recordFieldInputDraftValueComponentState({ scopeId }), newValue); + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/selectors/recordFieldInputDraftValueSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-field/states/selectors/recordFieldInputDraftValueSelectorScopeMap.ts deleted file mode 100644 index e785ec31374a..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/states/selectors/recordFieldInputDraftValueSelectorScopeMap.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { recordFieldInputDraftValueStateScopeMap } from '@/object-record/record-field/states/recordFieldInputDraftValueStateScopeMap'; -import { createSelectorScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorScopeMap'; - -export const recordFieldInputDraftValueSelectorScopeMap = - createSelectorScopeMap({ - key: 'recordFieldInputDraftValueSelectorScopeMap', - get: - ({ scopeId }: { scopeId: string }) => - ({ get }) => - get(recordFieldInputDraftValueStateScopeMap({ scopeId })) as T, - set: - ({ scopeId }: { scopeId: string }) => - ({ set }, newValue: T) => { - set(recordFieldInputDraftValueStateScopeMap({ scopeId }), newValue); - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts index a6611458e265..9316293d82c4 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldDefinition.ts @@ -1,5 +1,6 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + import { FieldMetadata } from './FieldMetadata'; -import { FieldType } from './FieldType'; export type FieldDefinitionRelationType = | 'FROM_MANY_OBJECTS' @@ -7,6 +8,11 @@ export type FieldDefinitionRelationType = | 'TO_MANY_OBJECTS' | 'TO_ONE_OBJECT'; +export type RelationDirections = { + from: FieldDefinitionRelationType; + to: FieldDefinitionRelationType; +}; + export type FieldDefinition = { fieldMetadataId: string; label: string; @@ -14,7 +20,8 @@ export type FieldDefinition = { disableTooltip?: boolean; labelWidth?: number; iconName: string; - type: FieldType; + type: FieldMetadataType; metadata: T; infoTooltipContent?: string; + defaultValue: any; }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts index 6aea22052e45..103bb49dbd36 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldInputDraftValue.ts @@ -1,11 +1,13 @@ import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; import { + FieldAddressValue, FieldBooleanValue, FieldCurrencyValue, FieldDateTimeValue, FieldEmailValue, FieldFullNameValue, FieldLinkValue, + FieldMultiSelectValue, FieldNumberValue, FieldPhoneValue, FieldRatingValue, @@ -21,6 +23,7 @@ export type FieldDateTimeDraftValue = string; export type FieldPhoneDraftValue = string; export type FieldEmailDraftValue = string; export type FieldSelectDraftValue = string; +export type FieldMultiSelectDraftValue = string[]; export type FieldRelationDraftValue = string; export type FieldLinkDraftValue = { url: string; label: string }; export type FieldCurrencyDraftValue = { @@ -28,6 +31,16 @@ export type FieldCurrencyDraftValue = { amount: string; }; export type FieldFullNameDraftValue = { firstName: string; lastName: string }; +export type FieldAddressDraftValue = { + addressStreet1: string; + addressStreet2: string | null; + addressCity: string | null; + addressState: string | null; + addressPostcode: string | null; + addressCountry: string | null; + addressLat: number | null; + addressLng: number | null; +}; export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldTextDraftValue @@ -53,6 +66,10 @@ export type FieldInputDraftValue = FieldValue extends FieldTextValue ? FieldRatingValue : FieldValue extends FieldSelectValue ? FieldSelectDraftValue - : FieldValue extends FieldRelationValue - ? FieldRelationDraftValue - : never; + : FieldValue extends FieldMultiSelectValue + ? FieldMultiSelectDraftValue + : FieldValue extends FieldRelationValue + ? FieldRelationDraftValue + : FieldValue extends FieldAddressValue + ? FieldAddressDraftValue + : never; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts index 74f8bbe88461..a4ad442cad35 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/FieldMetadata.ts @@ -26,6 +26,12 @@ export type FieldDateTimeMetadata = { fieldName: string; }; +export type FieldDateMetadata = { + objectMetadataNameSingular?: string; + placeHolder: string; + fieldName: string; +}; + export type FieldNumberMetadata = { objectMetadataNameSingular?: string; fieldName: string; @@ -69,6 +75,18 @@ export type FieldRatingMetadata = { fieldName: string; }; +export type FieldAddressMetadata = { + objectMetadataNameSingular?: string; + placeHolder: string; + fieldName: string; +}; + +export type FieldRawJsonMetadata = { + objectMetadataNameSingular?: string; + fieldName: string; + placeHolder: string; +}; + export type FieldDefinitionRelationType = | 'FROM_MANY_OBJECTS' | 'FROM_ONE_OBJECT' @@ -91,10 +109,17 @@ export type FieldSelectMetadata = { options: { label: string; color: ThemeColor; value: string }[]; }; +export type FieldMultiSelectMetadata = { + objectMetadataNameSingular?: string; + fieldName: string; + options: { label: string; color: ThemeColor; value: string }[]; +}; + export type FieldMetadata = | FieldBooleanMetadata | FieldCurrencyMetadata | FieldDateTimeMetadata + | FieldDateMetadata | FieldEmailMetadata | FieldFullNameMetadata | FieldLinkMetadata @@ -103,12 +128,15 @@ export type FieldMetadata = | FieldRatingMetadata | FieldRelationMetadata | FieldSelectMetadata + | FieldMultiSelectMetadata | FieldTextMetadata - | FieldUuidMetadata; + | FieldUuidMetadata + | FieldAddressMetadata; export type FieldTextValue = string; export type FieldUUidValue = string; export type FieldDateTimeValue = string | null; +export type FieldDateValue = string | null; export type FieldNumberValue = number | null; export type FieldBooleanValue = boolean; @@ -120,7 +148,19 @@ export type FieldCurrencyValue = { amountMicros: number | null; }; export type FieldFullNameValue = { firstName: string; lastName: string }; +export type FieldAddressValue = { + addressStreet1: string; + addressStreet2: string | null; + addressCity: string | null; + addressState: string | null; + addressPostcode: string | null; + addressCountry: string | null; + addressLat: number | null; + addressLng: number | null; +}; export type FieldRatingValue = (typeof RATING_VALUES)[number]; export type FieldSelectValue = string | null; +export type FieldMultiSelectValue = string[] | null; export type FieldRelationValue = EntityForSelect | null; +export type FieldJsonValue = string; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts b/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts deleted file mode 100644 index 1c734a526ace..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-field/types/FieldType.ts +++ /dev/null @@ -1,18 +0,0 @@ -export type FieldType = - | 'BOOLEAN' - | 'CHIP' - | 'CURRENCY' - | 'DATE_TIME' - | 'DOUBLE_TEXT_CHIP' - | 'DOUBLE_TEXT' - | 'EMAIL' - | 'FULL_NAME' - | 'LINK' - | 'NUMBER' - | 'PHONE' - | 'RATING' - | 'RELATION' - | 'SELECT' - | 'TEXT' - | 'URL' - | 'UUID'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts index 86f1922cbeea..614a9d838d8e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/assertFieldMetadata.ts @@ -1,24 +1,29 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql'; + import { FieldDefinition } from '../FieldDefinition'; import { + FieldAddressMetadata, FieldBooleanMetadata, FieldCurrencyMetadata, + FieldDateMetadata, FieldDateTimeMetadata, FieldEmailMetadata, FieldFullNameMetadata, FieldLinkMetadata, FieldMetadata, + FieldMultiSelectMetadata, FieldNumberMetadata, FieldPhoneMetadata, FieldRatingMetadata, + FieldRawJsonMetadata, FieldRelationMetadata, FieldSelectMetadata, FieldTextMetadata, FieldUuidMetadata, } from '../FieldMetadata'; -import { FieldType } from '../FieldType'; type AssertFieldMetadataFunction = < - E extends FieldType, + E extends FieldMetadataType, T extends E extends 'BOOLEAN' ? FieldBooleanMetadata : E extends 'CURRENCY' @@ -27,27 +32,35 @@ type AssertFieldMetadataFunction = < ? FieldFullNameMetadata : E extends 'DATE_TIME' ? FieldDateTimeMetadata - : E extends 'EMAIL' - ? FieldEmailMetadata - : E extends 'SELECT' - ? FieldSelectMetadata - : E extends 'RATING' - ? FieldRatingMetadata - : E extends 'LINK' - ? FieldLinkMetadata - : E extends 'NUMBER' - ? FieldNumberMetadata - : E extends 'PHONE' - ? FieldPhoneMetadata - : E extends 'PROBABILITY' - ? FieldRatingMetadata - : E extends 'RELATION' - ? FieldRelationMetadata - : E extends 'TEXT' - ? FieldTextMetadata - : E extends 'UUID' - ? FieldUuidMetadata - : never, + : E extends 'DATE' + ? FieldDateMetadata + : E extends 'EMAIL' + ? FieldEmailMetadata + : E extends 'SELECT' + ? FieldSelectMetadata + : E extends 'MULTI_SELECT' + ? FieldMultiSelectMetadata + : E extends 'RATING' + ? FieldRatingMetadata + : E extends 'LINK' + ? FieldLinkMetadata + : E extends 'NUMBER' + ? FieldNumberMetadata + : E extends 'PHONE' + ? FieldPhoneMetadata + : E extends 'PROBABILITY' + ? FieldRatingMetadata + : E extends 'RELATION' + ? FieldRelationMetadata + : E extends 'TEXT' + ? FieldTextMetadata + : E extends 'UUID' + ? FieldUuidMetadata + : E extends 'ADDRESS' + ? FieldAddressMetadata + : E extends 'RAW_JSON' + ? FieldRawJsonMetadata + : never, >( fieldType: E, fieldTypeGuard: ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts new file mode 100644 index 000000000000..c552808118ef --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddress.ts @@ -0,0 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldAddressMetadata, FieldMetadata } from '../FieldMetadata'; + +export const isFieldAddress = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.Address; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts new file mode 100644 index 000000000000..8bc33766e803 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldAddressValue.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { FieldAddressValue } from '../FieldMetadata'; + +const addressSchema = z.object({ + addressStreet1: z.string(), + addressStreet2: z.string().nullable(), + addressCity: z.string().nullable(), + addressState: z.string().nullable(), + addressPostcode: z.string().nullable(), + addressCountry: z.string().nullable(), + addressLat: z.number().nullable(), + addressLng: z.number().nullable(), +}); + +export const isFieldAddressValue = ( + fieldValue: unknown, +): fieldValue is FieldAddressValue => + addressSchema.safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldBoolean.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldBoolean.ts index d0c2a09d7fcb..69add94d5629 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldBoolean.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldBoolean.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldBooleanMetadata, FieldMetadata } from '../FieldMetadata'; export const isFieldBoolean = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'BOOLEAN'; +): field is FieldDefinition => + field.type === FieldMetadataType.Boolean; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldCurrency.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldCurrency.ts index 1cba9a16996d..b6738c0bb689 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldCurrency.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldCurrency.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldCurrencyMetadata, FieldMetadata } from '../FieldMetadata'; export const isFieldCurrency = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'CURRENCY'; +): field is FieldDefinition => + field.type === FieldMetadataType.Currency; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDate.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDate.ts new file mode 100644 index 000000000000..513d41256b21 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDate.ts @@ -0,0 +1,6 @@ +import { FieldDefinition } from '../FieldDefinition'; +import { FieldDateMetadata, FieldMetadata } from '../FieldMetadata'; + +export const isFieldDate = ( + field: Pick, 'type'>, +): field is FieldDefinition => field.type === 'DATE'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateTime.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateTime.ts index f98fa6846aa7..15c2a01c5347 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateTime.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateTime.ts @@ -1,7 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldDateTimeMetadata, FieldMetadata } from '../FieldMetadata'; export const isFieldDateTime = ( field: Pick, 'type'>, ): field is FieldDefinition => - field.type === 'DATE_TIME'; + field.type === FieldMetadataType.DateTime; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateValue.ts new file mode 100644 index 000000000000..e0fba3239f7a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldDateValue.ts @@ -0,0 +1,10 @@ +import { isNull, isString } from '@sniptt/guards'; + +import { FieldDateValue } from '@/object-record/record-field/types/FieldMetadata'; + +// TODO: add zod +export const isFieldDateValue = ( + fieldValue: unknown, +): fieldValue is FieldDateValue => + (isString(fieldValue) && !isNaN(Date.parse(fieldValue))) || + isNull(fieldValue); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmail.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmail.ts index bd5f5e4c12e9..265301773c4f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmail.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldEmail.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldEmailMetadata, FieldMetadata } from '../FieldMetadata'; export const isFieldEmail = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'EMAIL'; +): field is FieldDefinition => + field.type === FieldMetadataType.Email; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullName.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullName.ts index be25711176ed..a6261d6428d6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullName.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldFullName.ts @@ -1,7 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldFullNameMetadata, FieldMetadata } from '../FieldMetadata'; export const isFieldFullName = ( field: Pick, 'type'>, ): field is FieldDefinition => - field.type === 'FULL_NAME'; + field.type === FieldMetadataType.FullName; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLink.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLink.ts index 0c957c004236..526881a8a356 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLink.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldLink.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldLinkMetadata, FieldMetadata } from '../FieldMetadata'; export const isFieldLink = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'LINK'; +): field is FieldDefinition => + field.type === FieldMetadataType.Link; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts new file mode 100644 index 000000000000..e799738e6894 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelect.ts @@ -0,0 +1,11 @@ +import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition.ts'; +import { + FieldMetadata, + FieldMultiSelectMetadata, +} from '@/object-record/record-field/types/FieldMetadata.ts'; +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + +export const isFieldMultiSelect = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.MultiSelect; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts new file mode 100644 index 000000000000..01d2b47ff8ef --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldMultiSelectValue.ts @@ -0,0 +1,9 @@ +import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata.ts'; +import { multiSelectFieldValueSchema } from '@/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts'; + +export const isFieldMultiSelectValue = ( + fieldValue: unknown, + options?: string[], +): fieldValue is FieldMultiSelectValue => { + return multiSelectFieldValueSchema(options).safeParse(fieldValue).success; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldNumber.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldNumber.ts index b4b5c7b8d109..6b863170ad92 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldNumber.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldNumber.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldMetadata, FieldNumberMetadata } from '../FieldMetadata'; export const isFieldNumber = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'NUMBER'; +): field is FieldDefinition => + field.type === FieldMetadataType.Number; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhone.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhone.ts index 9e84a5c42662..a417d1fb0d34 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhone.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldPhone.ts @@ -1,3 +1,5 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldMetadata, FieldPhoneMetadata } from '../FieldMetadata'; @@ -6,4 +8,4 @@ export const isFieldPhone = ( ): field is FieldDefinition => field.metadata.objectMetadataNameSingular === 'person' && field.metadata.fieldName === 'phone' && - field.type === 'TEXT'; + field.type === FieldMetadataType.Text; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJson.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJson.ts new file mode 100644 index 000000000000..1cdc93665cea --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJson.ts @@ -0,0 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + +import { FieldDefinition } from '../FieldDefinition'; +import { FieldMetadata, FieldRawJsonMetadata } from '../FieldMetadata'; + +export const isFieldRawJson = ( + field: Pick, 'type'>, +): field is FieldDefinition => + field.type === FieldMetadataType.RawJson; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts new file mode 100644 index 000000000000..8c7657d8dc4b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRawJsonValue.ts @@ -0,0 +1,8 @@ +import { isNull, isString } from '@sniptt/guards'; + +import { FieldJsonValue } from '../FieldMetadata'; + +// TODO: add zod +export const isFieldRawJsonValue = ( + fieldValue: unknown, +): fieldValue is FieldJsonValue => isString(fieldValue) || isNull(fieldValue); diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelation.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelation.ts index 2927569c3514..b64046647cff 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelation.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldRelation.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldMetadata, FieldRelationMetadata } from '../FieldMetadata'; export const isFieldRelation = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'RELATION'; +): field is FieldDefinition => + field.type === FieldMetadataType.Relation; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldSelectValue.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldSelectValue.ts index 55dbaa4a605c..0c95b795b2ac 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldSelectValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldSelectValue.ts @@ -1,7 +1,8 @@ -import { isString } from '@sniptt/guards'; - import { FieldSelectValue } from '@/object-record/record-field/types/FieldMetadata'; +import { selectFieldValueSchema } from '@/object-record/record-field/validation-schemas/selectFieldValueSchema'; export const isFieldSelectValue = ( fieldValue: unknown, -): fieldValue is FieldSelectValue => isString(fieldValue); + options?: string[], +): fieldValue is FieldSelectValue => + selectFieldValueSchema(options).safeParse(fieldValue).success; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldText.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldText.ts index 7c1b5eee1250..197c46046742 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldText.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldText.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldMetadata, FieldTextMetadata } from '../FieldMetadata'; export const isFieldText = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'TEXT'; +): field is FieldDefinition => + field.type === FieldMetadataType.Text; diff --git a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldUuid.ts b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldUuid.ts index 84f83e2b1f82..a8bf679c555e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldUuid.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/types/guards/isFieldUuid.ts @@ -1,6 +1,9 @@ +import { FieldMetadataType } from '~/generated-metadata/graphql.ts'; + import { FieldDefinition } from '../FieldDefinition'; import { FieldMetadata, FieldUuidMetadata } from '../FieldMetadata'; export const isFieldUuid = ( field: Pick, 'type'>, -): field is FieldDefinition => field.type === 'UUID'; +): field is FieldDefinition => + field.type === FieldMetadataType.Uuid; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts index ee11e822cbfa..72c024f7afe9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/computeDraftValueFromFieldValue.ts @@ -1,4 +1,3 @@ -import { CurrencyCode } from '@/object-record/record-field/types/CurrencyCode'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldInputDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; @@ -31,7 +30,7 @@ export const computeDraftValueFromFieldValue = ({ return { amount: fieldValue?.amountMicros ? fieldValue.amountMicros / 1000000 : '', - currenyCode: CurrencyCode.USD, + currencyCode: fieldValue?.currencyCode ?? '', } as unknown as FieldInputDraftValue; } if (isFieldRelation(fieldDefinition)) { 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 d4f8877f555f..ded220c98888 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,52 +1,68 @@ import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress'; +import { isFieldAddressValue } from '@/object-record/record-field/types/guards/isFieldAddressValue'; import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; import { isFieldCurrency } from '@/object-record/record-field/types/guards/isFieldCurrency'; import { isFieldCurrencyValue } from '@/object-record/record-field/types/guards/isFieldCurrencyValue'; +import { isFieldDate } from '@/object-record/record-field/types/guards/isFieldDate'; import { isFieldDateTime } from '@/object-record/record-field/types/guards/isFieldDateTime'; import { isFieldEmail } from '@/object-record/record-field/types/guards/isFieldEmail'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldFullNameValue } from '@/object-record/record-field/types/guards/isFieldFullNameValue'; import { isFieldLink } from '@/object-record/record-field/types/guards/isFieldLink'; import { isFieldLinkValue } from '@/object-record/record-field/types/guards/isFieldLinkValue'; +import { isFieldMultiSelect } from '@/object-record/record-field/types/guards/isFieldMultiSelect.ts'; +import { isFieldMultiSelectValue } from '@/object-record/record-field/types/guards/isFieldMultiSelectValue.ts'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating'; +import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRelation } from '@/object-record/record-field/types/guards/isFieldRelation'; -import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue'; import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect'; import { isFieldSelectValue } from '@/object-record/record-field/types/guards/isFieldSelectValue'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; import { isFieldUuid } from '@/object-record/record-field/types/guards/isFieldUuid'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; -const isValueEmpty = (value: unknown) => !isNonNullable(value) || value === ''; +const isValueEmpty = (value: unknown) => !isDefined(value) || value === ''; export const isFieldValueEmpty = ({ fieldDefinition, fieldValue, + selectOptionValues, }: { fieldDefinition: Pick, 'type'>; fieldValue: unknown; + selectOptionValues?: string[]; }) => { if ( isFieldUuid(fieldDefinition) || isFieldText(fieldDefinition) || isFieldDateTime(fieldDefinition) || + isFieldDate(fieldDefinition) || isFieldNumber(fieldDefinition) || isFieldRating(fieldDefinition) || isFieldEmail(fieldDefinition) || - isFieldBoolean(fieldDefinition) + isFieldBoolean(fieldDefinition) || + isFieldRelation(fieldDefinition) || + isFieldRawJson(fieldDefinition) //|| isFieldPhone(fieldDefinition) ) { return isValueEmpty(fieldValue); } - if (isFieldRelation(fieldDefinition)) { - return isFieldRelationValue(fieldValue) && isValueEmpty(fieldValue); + if (isFieldSelect(fieldDefinition)) { + return ( + !isFieldSelectValue(fieldValue, selectOptionValues) || + !isDefined(fieldValue) + ); } - if (isFieldSelect(fieldDefinition)) { - return isFieldSelectValue(fieldValue) && !isNonNullable(fieldValue); + if (isFieldMultiSelect(fieldDefinition)) { + return ( + !isFieldMultiSelectValue(fieldValue, selectOptionValues) || + !isDefined(fieldValue) + ); } if (isFieldCurrency(fieldDefinition)) { @@ -67,7 +83,19 @@ export const isFieldValueEmpty = ({ return !isFieldLinkValue(fieldValue) || isValueEmpty(fieldValue?.url); } + if (isFieldAddress(fieldDefinition)) { + return ( + !isFieldAddressValue(fieldValue) || + (isValueEmpty(fieldValue?.addressStreet1) && + isValueEmpty(fieldValue?.addressStreet2) && + isValueEmpty(fieldValue?.addressCity) && + isValueEmpty(fieldValue?.addressState) && + isValueEmpty(fieldValue?.addressPostcode) && + isValueEmpty(fieldValue?.addressCountry)) + ); + } + throw new Error( - `Entity field type not supported in isEntityFieldEditModeEmptyFamilySelector : ${fieldDefinition.type}}`, + `Entity field type not supported in isFieldValueEmpty : ${fieldDefinition.type}}`, ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueJson.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueJson.ts new file mode 100644 index 000000000000..6e577e7794d5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueJson.ts @@ -0,0 +1,12 @@ +import { isString } from '@sniptt/guards'; + +export const isValidJSON = (str: string) => { + try { + if (isString(JSON.parse(str))) { + throw new Error(`Strings are not supported as JSON: ${str}`); + } + return true; + } catch (error) { + return false; + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts new file mode 100644 index 000000000000..c4da1fc75ce6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/multiSelectFieldValueSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { FieldMultiSelectValue } from '@/object-record/record-field/types/FieldMetadata'; + +export const multiSelectFieldValueSchema = ( + options?: string[], +): z.ZodType => + options?.length + ? z.array(z.enum(options as [string, ...string[]])).nullable() + : z.array(z.string()).nullable(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/selectFieldValueSchema.ts b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/selectFieldValueSchema.ts new file mode 100644 index 000000000000..ca79234e8d89 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/validation-schemas/selectFieldValueSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod'; + +import { FieldSelectValue } from '@/object-record/record-field/types/FieldMetadata'; + +export const selectFieldValueSchema = ( + options?: string[], +): z.ZodType => + options?.length + ? z.enum(options as [string, ...string[]]).nullable() + : z.string().nullable(); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts index e86ac4da627c..768221f5f8a8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectRecordQueryFilter.ts @@ -71,6 +71,15 @@ export type FullNameFilter = { lastName?: StringFilter; }; +export type AddressFilter = { + addressStreet1?: StringFilter; + addressStreet2?: StringFilter; + addressCity?: StringFilter; + addressState?: StringFilter; + addressCountry?: StringFilter; + addressPostcode?: StringFilter; +}; + export type LeafFilter = | UUIDFilter | StringFilter @@ -80,6 +89,7 @@ export type LeafFilter = | URLFilter | FullNameFilter | BooleanFilter + | AddressFilter | undefined; export type AndObjectRecordFilter = { 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 9a2ff1ff84c2..53a803ef266d 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 @@ -2,6 +2,7 @@ import { isObject } from '@sniptt/guards'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { + AddressFilter, AndObjectRecordFilter, BooleanFilter, CurrencyFilter, @@ -22,8 +23,8 @@ import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMat import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; import { isEmptyObject } from '~/utils/isEmptyObject'; -import { isNonNullable } from '~/utils/isNonNullable'; const isAndFilter = ( filter: ObjectRecordQueryFilter, @@ -102,7 +103,7 @@ export const isRecordMatchingFilter = ({ if (isNotFilter(filter)) { const filterValue = filter.not; - if (!isNonNullable(filterValue)) { + if (!isDefined(filterValue)) { throw new Error('Unexpected value for "not" filter : ' + filterValue); } @@ -117,7 +118,7 @@ export const isRecordMatchingFilter = ({ } return Object.entries(filter).every(([filterKey, filterValue]) => { - if (!isNonNullable(filterValue)) { + if (!isDefined(filterValue)) { throw new Error( 'Unexpected value for filter key "' + filterKey + '" : ' + filterValue, ); @@ -129,7 +130,7 @@ export const isRecordMatchingFilter = ({ (field) => field.name === filterKey, ); - if (!isNonNullable(objectMetadataField)) { + if (!isDefined(objectMetadataField)) { throw new Error( 'Field metadata item "' + filterKey + @@ -141,6 +142,8 @@ export const isRecordMatchingFilter = ({ switch (objectMetadataField.type) { case FieldMetadataType.Email: case FieldMetadataType.Phone: + case FieldMetadataType.Select: + case FieldMetadataType.MultiSelect: case FieldMetadataType.Text: { return isMatchingStringFilter({ stringFilter: filterValue as StringFilter, @@ -179,6 +182,30 @@ export const isRecordMatchingFilter = ({ })) ); } + case FieldMetadataType.Address: { + const addressFilter = filterValue as AddressFilter; + + const keys = [ + 'addressStreet1', + 'addressStreet2', + 'addressCity', + 'addressState', + 'addressCountry', + 'addressPostcode', + ] as const; + + return keys.some((key) => { + const value = addressFilter[key]; + if (value === undefined) { + return false; + } + + return isMatchingStringFilter({ + stringFilter: value, + value: record[filterKey][key], + }); + }); + } case FieldMetadataType.DateTime: { return isMatchingDateFilter({ dateFilter: filterValue as DateFilter, 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 index 352166c0a96b..c6974c4356b1 100644 --- 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 @@ -1,4 +1,7 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { + AddressFilter, CurrencyFilter, DateFilter, FloatFilter, @@ -11,7 +14,7 @@ import { import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { Field } from '~/generated/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { Filter } from '../../object-filter-dropdown/types/Filter'; @@ -33,13 +36,11 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( ); if (!correspondingField) { - throw new Error( - `Could not find field ${rawUIFilter.fieldMetadataId} in metadata object`, - ); + continue; } - if (!isNonNullable(rawUIFilter.value) || rawUIFilter.value === '') { - return undefined; + if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') { + continue; } switch (rawUIFilter.definition.type) { @@ -134,7 +135,7 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( }); break; case ViewFilterOperand.IsNot: - if (parsedRecordIds.length) { + if (parsedRecordIds.length > 0) { objectRecordFilters.push({ not: { [correspondingField.name + 'Id']: { @@ -254,6 +255,116 @@ export const turnObjectDropdownFilterIntoQueryFilter = ( ); } break; + case 'ADDRESS': + switch (rawUIFilter.operand) { + case ViewFilterOperand.Contains: + 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, + }, + ], + }); + break; + case ViewFilterOperand.DoesNotContain: + 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, + }, + }, + ], + }); + break; + default: + throw new Error( + `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, + ); + } + break; + case 'SELECT': { + 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; + } default: throw new Error('Unknown filter type'); } 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 2fd30265fc38..bd412f91020e 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 @@ -1,4 +1,6 @@ -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useRecoilValue } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; @@ -6,7 +8,7 @@ import { RecordBoardActionBar } from '@/object-record/record-board/action-bar/co import { RecordBoard } from '@/object-record/record-board/components/RecordBoard'; import { RecordBoardContextMenu } from '@/object-record/record-board/context-menu/components/RecordBoardContextMenu'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; type RecordIndexBoardContainerProps = { recordBoardId: string; @@ -19,12 +21,16 @@ export const RecordIndexBoardContainer = ({ recordBoardId, objectNameSingular, }: RecordIndexBoardContainerProps) => { - const { objectMetadataItem } = useObjectMetadataItemOnly({ + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); + const recordIndexKanbanFieldMetadataId = useRecoilValue( + recordIndexKanbanFieldMetadataIdState, + ); + const selectFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.type === FieldMetadataType.Select, + (field) => field.id === recordIndexKanbanFieldMetadataId, ); const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx index 44f0ab12af37..4a755169d0e6 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainerEffect.tsx @@ -2,13 +2,16 @@ import { useCallback, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useRecordActionBar } from '@/object-record/record-action-bar/hooks/useRecordActionBar'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { useLoadRecordIndexBoard } from '@/object-record/record-index/hooks/useLoadRecordIndexBoard'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; +import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; type RecordIndexBoardContainerEffectProps = { objectNameSingular: string; @@ -21,16 +24,17 @@ export const RecordIndexBoardContainerEffect = ({ recordBoardId, viewBarId, }: RecordIndexBoardContainerEffectProps) => { - const { objectMetadataItem } = useObjectMetadataItemOnly({ + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); const { setColumns, setObjectSingularName, - getSelectedRecordIdsSelector, + selectedRecordIdsSelector, setFieldDefinitions, - getOnFetchMoreVisibilityChangeState, + onFetchMoreVisibilityChangeState, + setKanbanFieldMetadataName, } = useRecordBoard(recordBoardId); const { fetchMoreRecords, loading } = useLoadRecordIndexBoard({ @@ -40,7 +44,11 @@ export const RecordIndexBoardContainerEffect = ({ }); const setOnFetchMoreVisibilityChange = useSetRecoilState( - getOnFetchMoreVisibilityChangeState(), + onFetchMoreVisibilityChangeState, + ); + + const recordIndexKanbanFieldMetadataId = useRecoilValue( + recordIndexKanbanFieldMetadataIdState, ); useEffect(() => { @@ -67,6 +75,7 @@ export const RecordIndexBoardContainerEffect = ({ setColumns( computeRecordBoardColumnDefinitionsFromObjectMetadata( objectMetadataItem, + recordIndexKanbanFieldMetadataId ?? '', navigateToSelectSettings, ), ); @@ -74,6 +83,7 @@ export const RecordIndexBoardContainerEffect = ({ navigateToSelectSettings, objectMetadataItem, objectNameSingular, + recordIndexKanbanFieldMetadataId, setColumns, ]); @@ -85,7 +95,25 @@ export const RecordIndexBoardContainerEffect = ({ setFieldDefinitions(recordIndexFieldDefinitions); }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); - const selectedRecordIds = useRecoilValue(getSelectedRecordIdsSelector()); + useEffect(() => { + if (isDefined(recordIndexKanbanFieldMetadataId)) { + const kanbanFieldMetadataName = objectMetadataItem?.fields.find( + (field) => + field.type === FieldMetadataType.Select && + field.id === recordIndexKanbanFieldMetadataId, + )?.name; + + if (isDefined(kanbanFieldMetadataName)) { + setKanbanFieldMetadataName(kanbanFieldMetadataName); + } + } + }, [ + objectMetadataItem, + recordIndexKanbanFieldMetadataId, + setKanbanFieldMetadataName, + ]); + + const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ objectMetadataItem, 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 81679b9c64f2..3fceb3f2cce3 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 @@ -10,10 +10,10 @@ import { RecordIndexTableContainer } from '@/object-record/record-index/componen import { RecordIndexTableContainerEffect } from '@/object-record/record-index/components/RecordIndexTableContainerEffect'; import { RecordIndexViewBarEffect } from '@/object-record/record-index/components/RecordIndexViewBarEffect'; import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; 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 { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; @@ -57,7 +57,7 @@ export const RecordIndexContainer = ({ objectNameSingular, }); - const { columnDefinitions } = + const { columnDefinitions, filterDefinitions, sortDefinitions } = useColumnDefinitionsFromFieldMetadata(objectMetadataItem); const setRecordIndexFilters = useSetRecoilState(recordIndexFiltersState); @@ -65,6 +65,9 @@ export const RecordIndexContainer = ({ const setRecordIndexIsCompactModeActive = useSetRecoilState( recordIndexIsCompactModeActiveState, ); + const setRecordIndexViewKanbanFieldMetadataIdState = useSetRecoilState( + recordIndexKanbanFieldMetadataIdState, + ); const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({ recordTableId: recordIndexId, @@ -112,21 +115,27 @@ export const RecordIndexContainer = ({ viewType={recordIndexViewType ?? ViewType.Table} /> } - optionsDropdownScopeId={RECORD_INDEX_OPTIONS_DROPDOWN_ID} - onViewFieldsChange={onViewFieldsChange} - onViewFiltersChange={(viewFilters) => { - setTableFilters(mapViewFiltersToFilters(viewFilters)); - setRecordIndexFilters(mapViewFiltersToFilters(viewFilters)); - }} - onViewSortsChange={(viewSorts) => { - setTableSorts(mapViewSortsToSorts(viewSorts)); - setRecordIndexSorts(mapViewSortsToSorts(viewSorts)); - }} - onViewTypeChange={(viewType: ViewType) => { - setRecordIndexViewType(viewType); - }} - onViewCompactModeChange={(isCompactModeActive: boolean) => { - setRecordIndexIsCompactModeActive(isCompactModeActive); + 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); }} /> { setAvailableTableColumns(columnDefinitions); }, [columnDefinitions, setAvailableTableColumns]); - const selectedRowIds = useRecoilValue(getSelectedRowIdsSelector()); + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); const { setActionBarEntries, setContextMenuEntries } = useRecordActionBar({ objectMetadataItem, @@ -50,6 +53,30 @@ export const RecordIndexTableContainerEffect = ({ callback: resetTableRowSelection, }); + const handleToggleColumnFilter = useHandleToggleColumnFilter({ + objectNameSingular, + viewBarId, + }); + + const handleToggleColumnSort = useHandleToggleColumnSort({ + objectNameSingular, + viewBarId, + }); + + useEffect(() => { + setOnToggleColumnFilter( + () => (fieldMetadataId: string) => + handleToggleColumnFilter(fieldMetadataId), + ); + }, [setOnToggleColumnFilter, handleToggleColumnFilter]); + + useEffect(() => { + setOnToggleColumnSort( + () => (fieldMetadataId: string) => + handleToggleColumnSort(fieldMetadataId), + ); + }, [setOnToggleColumnSort, handleToggleColumnSort]); + useEffect(() => { setActionBarEntries?.(); setContextMenuEntries?.(); @@ -57,9 +84,9 @@ export const RecordIndexTableContainerEffect = ({ useEffect(() => { setOnEntityCountChange( - () => (entityCount: number) => setEntityCountInCurrentView(entityCount), + () => (entityCount: number) => setRecordCountInCurrentView(entityCount), ); - }, [setEntityCountInCurrentView, setOnEntityCountChange]); + }, [setRecordCountInCurrentView, setOnEntityCountChange]); return <>; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewBarEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewBarEffect.tsx index 31a4447b2683..44ae8879625f 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewBarEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexViewBarEffect.tsx @@ -3,7 +3,8 @@ import { useEffect } from 'react'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; -import { useViewBar } from '@/views/hooks/useViewBar'; +import { useInitViewBar } from '@/views/hooks/useInitViewBar'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; type RecordIndexViewBarEffectProps = { objectNamePlural: string; @@ -30,10 +31,10 @@ export const RecordIndexViewBarEffect = ({ setAvailableSortDefinitions, setAvailableFilterDefinitions, setAvailableFieldDefinitions, - } = useViewBar({ viewBarId }); + } = useInitViewBar(viewBarId); useEffect(() => { - if (!objectMetadataItem) { + if (isUndefinedOrNull(objectMetadataItem)) { return; } setViewObjectMetadataId?.(objectMetadataItem.id); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts new file mode 100644 index 000000000000..774e604178af --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnFilter.ts @@ -0,0 +1,71 @@ +import { useCallback } from 'react'; + +import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; +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 { getOperandsForFilterType } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType'; +import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; +import { useCombinedViewFilters } from '@/views/hooks/useCombinedViewFilters'; +import { isDefined } from '~/utils/isDefined'; + +type UseHandleToggleColumnFilterProps = { + objectNameSingular: string; + viewBarId: string; +}; + +export const useHandleToggleColumnFilter = ({ + viewBarId, + objectNameSingular, +}: UseHandleToggleColumnFilterProps) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { columnDefinitions } = + useColumnDefinitionsFromFieldMetadata(objectMetadataItem); + + const { upsertCombinedViewFilter } = useCombinedViewFilters(viewBarId); + const { openDropdown } = useDropdownV2(); + + const handleToggleColumnFilter = useCallback( + (fieldMetadataId: string) => { + const correspondingColumnDefinition = columnDefinitions.find( + (columnDefinition) => + columnDefinition.fieldMetadataId === fieldMetadataId, + ); + + if (!isDefined(correspondingColumnDefinition)) return; + + const filterType = getFilterTypeFromFieldType( + correspondingColumnDefinition?.type, + ); + + const availableOperandsForFilter = getOperandsForFilterType(filterType); + + const defaultOperand = availableOperandsForFilter[0]; + + const newFilter: Filter = { + fieldMetadataId, + operand: defaultOperand, + displayValue: '', + definition: { + label: correspondingColumnDefinition.label, + iconName: correspondingColumnDefinition.iconName, + fieldMetadataId, + type: filterType, + }, + value: '', + }; + + upsertCombinedViewFilter(newFilter); + + openDropdown(fieldMetadataId, { + scope: fieldMetadataId, + }); + }, + [columnDefinitions, upsertCombinedViewFilter, openDropdown], + ); + + return handleToggleColumnFilter; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnSort.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnSort.ts new file mode 100644 index 000000000000..a5253a49e82a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleColumnSort.ts @@ -0,0 +1,52 @@ +import { useCallback } from 'react'; + +import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { Sort } from '@/object-record/object-sort-dropdown/types/Sort'; +import { useCombinedViewSorts } from '@/views/hooks/useCombinedViewSorts'; +import { isDefined } from '~/utils/isDefined'; + +type UseHandleToggleColumnSortProps = { + objectNameSingular: string; + viewBarId: string; +}; + +export const useHandleToggleColumnSort = ({ + viewBarId, + objectNameSingular, +}: UseHandleToggleColumnSortProps) => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { columnDefinitions } = + useColumnDefinitionsFromFieldMetadata(objectMetadataItem); + + const { upsertCombinedViewSort } = useCombinedViewSorts(viewBarId); + + const handleToggleColumnSort = useCallback( + (fieldMetadataId: string) => { + const correspondingColumnDefinition = columnDefinitions.find( + (columnDefinition) => + columnDefinition.fieldMetadataId === fieldMetadataId, + ); + + if (!isDefined(correspondingColumnDefinition)) return; + + const newSort: Sort = { + fieldMetadataId, + definition: { + fieldMetadataId, + label: correspondingColumnDefinition.label, + iconName: correspondingColumnDefinition.iconName, + }, + direction: 'asc', + }; + + upsertCombinedViewSort(newSort); + }, + [columnDefinitions, upsertCombinedViewSort], + ); + + return handleToggleColumnSort; +}; 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 622be1ecdfce..3e10a603374c 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 @@ -6,12 +6,13 @@ 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 { useRecordBoardQueryFields } from '@/object-record/record-index/hooks/useRecordBoardQueryFields'; 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 { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; -import { useViewBar } from '@/views/hooks/useViewBar'; +import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; type UseLoadRecordIndexBoardProps = { objectNameSingular: string; @@ -30,7 +31,7 @@ export const useLoadRecordIndexBoard = ({ const { setRecordIds: setRecordIdsInBoard, setFieldDefinitions, - getIsCompactModeActiveState, + isCompactModeActiveState, } = useRecordBoard(recordBoardId); const { setRecords: setRecordsInStore } = useSetRecordInStore(); @@ -47,15 +48,19 @@ export const useLoadRecordIndexBoard = ({ recordIndexFilters, objectMetadataItem?.fields ?? [], ); - const orderBy = turnSortsIntoOrderBy( - recordIndexSorts, - objectMetadataItem?.fields ?? [], - ); + const orderBy = !objectMetadataItem.isRemote + ? turnSortsIntoOrderBy(recordIndexSorts, objectMetadataItem?.fields ?? []) + : undefined; const recordIndexIsCompactModeActive = useRecoilValue( recordIndexIsCompactModeActiveState, ); + const queryFields = useRecordBoardQueryFields({ + objectMetadataItem, + recordBoardId, + }); + const { records, totalCount, @@ -66,15 +71,13 @@ export const useLoadRecordIndexBoard = ({ objectNameSingular, filter: requestFilters, orderBy, + queryFields, }); - const { setEntityCountInCurrentView } = useViewBar({ - viewBarId, - }); + const { setRecordCountInCurrentView } = + useSetRecordCountInCurrentView(viewBarId); - const setIsCompactModeActive = useSetRecoilState( - getIsCompactModeActiveState(), - ); + const setIsCompactModeActive = useSetRecoilState(isCompactModeActiveState); useEffect(() => { setRecordIdsInBoard(records); @@ -85,8 +88,8 @@ export const useLoadRecordIndexBoard = ({ }, [records, setRecordsInStore]); useEffect(() => { - setEntityCountInCurrentView(totalCount); - }, [totalCount, setEntityCountInCurrentView]); + setRecordCountInCurrentView(totalCount); + }, [totalCount, setRecordCountInCurrentView]); useEffect(() => { setIsCompactModeActive(recordIndexIsCompactModeActive); 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 48d9f64ef8f5..5fd83e30672b 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 @@ -2,29 +2,37 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState.ts'; 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 { useRecordTableQueryFields } from '@/object-record/record-index/hooks/useRecordTableQueryFields'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { SIGN_IN_BACKGROUND_MOCK_COMPANIES } from '@/sign-in-background-mock/constants/SignInBackgroundMockCompanies'; -import { useFindManyRecords } from '../../hooks/useFindManyRecords'; - -export const useFindManyParams = (objectNameSingular: string) => { +export const useFindManyParams = ( + objectNameSingular: string, + recordTableId?: string, +) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - const { getTableFiltersState, getTableSortsState } = useRecordTableStates(); + const { tableFiltersState, tableSortsState } = + useRecordTableStates(recordTableId); - const tableFilters = useRecoilValue(getTableFiltersState()); - const tableSorts = useRecoilValue(getTableSortsState()); + const tableFilters = useRecoilValue(tableFiltersState); + const tableSorts = useRecoilValue(tableSortsState); const filter = turnObjectDropdownFilterIntoQueryFilter( tableFilters, objectMetadataItem?.fields ?? [], ); + if (objectMetadataItem?.isRemote) { + return { objectNameSingular, filter }; + } + const orderBy = turnSortsIntoOrderBy( tableSorts, objectMetadataItem?.fields ?? [], @@ -36,11 +44,13 @@ export const useFindManyParams = (objectNameSingular: string) => { export const useLoadRecordIndexTable = (objectNameSingular: string) => { const { setRecordTableData, setIsRecordTableInitialLoading } = useRecordTable(); - const { getTableLastRowVisibleState } = useRecordTableStates(); - const setLastRowVisible = useSetRecoilState(getTableLastRowVisibleState()); + const { tableLastRowVisibleState } = useRecordTableStates(); + const setLastRowVisible = useSetRecoilState(tableLastRowVisibleState); const currentWorkspace = useRecoilValue(currentWorkspaceState); const params = useFindManyParams(objectNameSingular); + const queryFields = useRecordTableQueryFields(); + const { records, loading, @@ -49,6 +59,7 @@ export const useLoadRecordIndexTable = (objectNameSingular: string) => { queryStateIdentifier, } = useFindManyRecords({ ...params, + queryFields, onCompleted: () => { setLastRowVisible(false); setIsRecordTableInitialLoading(false); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordBoardQueryFields.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordBoardQueryFields.ts new file mode 100644 index 000000000000..7d1294ab601a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordBoardQueryFields.ts @@ -0,0 +1,52 @@ +import { useRecoilValue } from 'recoil'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields'; +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { isDefined } from '~/utils/isDefined'; + +export const useRecordBoardQueryFields = ({ + objectMetadataItem, + recordBoardId, +}: { + recordBoardId: string; + objectMetadataItem: ObjectMetadataItem; +}) => { + const { kanbanFieldMetadataNameState, visibleFieldDefinitionsState } = + useRecordBoardStates(recordBoardId); + + const { imageIdentifierFieldMetadataItem, labelIdentifierFieldMetadataItem } = + getObjectMetadataIdentifierFields({ objectMetadataItem }); + + const kanbanFieldMetadataName = useRecoilValue(kanbanFieldMetadataNameState); + const visibleFieldDefinitions = useRecoilValue( + visibleFieldDefinitionsState(), + ); + + const identifierQueryFields: Record = {}; + + if (isDefined(labelIdentifierFieldMetadataItem)) { + identifierQueryFields[labelIdentifierFieldMetadataItem.name] = true; + } + + if (isDefined(imageIdentifierFieldMetadataItem)) { + identifierQueryFields[imageIdentifierFieldMetadataItem.name] = true; + } + + const queryFields: Record = { + id: true, + ...Object.fromEntries( + visibleFieldDefinitions.map((visibleFieldDefinition) => [ + visibleFieldDefinition.metadata.fieldName, + true, + ]), + ), + ...identifierQueryFields, + }; + + if (isDefined(kanbanFieldMetadataName)) { + queryFields[kanbanFieldMetadataName] = true; + } + + return queryFields; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableQueryFields.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableQueryFields.ts new file mode 100644 index 000000000000..4b035140781f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableQueryFields.ts @@ -0,0 +1,18 @@ +import { useRecoilValue } from 'recoil'; + +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; + +export const useRecordTableQueryFields = () => { + const { visibleTableColumnsSelector } = useRecordTableStates(); + + const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); + + const queryFields: Record = { + id: true, + ...Object.fromEntries( + visibleTableColumns.map((column) => [column.metadata.fieldName, true]), + ), + }; + + return queryFields; +}; 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 d79427c0fef9..4b1d32086319 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,11 +1,8 @@ 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'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { useViewBar } from '@/views/hooks/useViewBar'; import { ViewType } from '@/views/types/ViewType'; type RecordIndexOptionsDropdownProps = { @@ -19,28 +16,19 @@ export const RecordIndexOptionsDropdown = ({ objectNameSingular, viewType, }: RecordIndexOptionsDropdownProps) => { - const { setViewEditMode } = useViewBar(); - const { scopeId } = useRecordTableStates(recordIndexId); - return ( - false} - > - } - dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} - dropdownOffset={{ y: 8 }} - dropdownComponents={ - - } - onClickOutside={() => setViewEditMode('none')} - /> - + } + dropdownHotkeyScope={{ scope: TableOptionsHotkeyScope.Dropdown }} + dropdownOffset={{ y: 8 }} + 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 d341b88b4636..dffa9d58e8e6 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,21 +1,20 @@ -import { useRef, useState } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useState } from 'react'; import { Key } from 'ts-key-enum'; - -import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; -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'; -import { useSpreadsheetRecordImport } from '@/object-record/spreadsheet-import/useSpreadsheetRecordImport'; import { IconBaselineDensitySmall, IconChevronLeft, IconFileExport, IconFileImport, IconTag, -} from '@/ui/display/icon'; +} from 'twenty-ui'; + +import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; +import { useExportTableData } from '@/object-record/record-index/options/hooks/useExportTableData.ts'; +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'; +import { useSpreadsheetRecordImport } from '@/object-record/spreadsheet-import/useSpreadsheetRecordImport'; 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 { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; @@ -23,12 +22,9 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; -import { useViewScopedStates } from '@/views/hooks/internal/useViewScopedStates'; -import { useViewBar } from '@/views/hooks/useViewBar'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { ViewType } from '@/views/types/ViewType'; -import { useExportTableData } from '../hooks/useExportTableData'; - type RecordIndexOptionsMenu = 'fields'; type RecordIndexOptionsDropdownContentProps = { @@ -42,13 +38,8 @@ export const RecordIndexOptionsDropdownContent = ({ recordIndexId, objectNameSingular, }: RecordIndexOptionsDropdownContentProps) => { - const { setViewEditMode, handleViewNameSubmit } = useViewBar({ - viewBarId: recordIndexId, - }); - const { viewEditModeState, currentViewSelector } = useViewScopedStates(); + const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); - const viewEditMode = useRecoilValue(viewEditModeState); - const currentView = useRecoilValue(currentViewSelector); const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); const [currentMenu, setCurrentMenu] = useState< @@ -57,8 +48,6 @@ export const RecordIndexOptionsDropdownContent = ({ const resetMenu = () => setCurrentMenu(undefined); - const viewEditInputRef = useRef(null); - const handleSelectMenu = (option: RecordIndexOptionsMenu) => { setCurrentMenu(option); }; @@ -71,18 +60,6 @@ export const RecordIndexOptionsDropdownContent = ({ TableOptionsHotkeyScope.Dropdown, ); - useScopedHotkeys( - Key.Enter, - () => { - const name = viewEditInputRef.current?.value; - handleViewNameSubmit(name); - resetMenu(); - setViewEditMode('none'); - closeDropdown(); - }, - TableOptionsHotkeyScope.Dropdown, - ); - const { handleColumnVisibilityChange, handleReorderColumns, @@ -132,38 +109,23 @@ export const RecordIndexOptionsDropdownContent = ({ return ( <> {!currentMenu && ( - <> - + handleSelectMenu('fields')} + LeftIcon={IconTag} + text="Fields" /> - - - handleSelectMenu('fields')} - LeftIcon={IconTag} - text="Fields" - /> - openRecordSpreadsheetImport()} - LeftIcon={IconFileImport} - text="Import" - /> - - - + openRecordSpreadsheetImport()} + LeftIcon={IconFileImport} + text="Import" + /> + + )} {currentMenu === 'fields' && ( <> @@ -200,7 +162,7 @@ export const RecordIndexOptionsDropdownContent = ({ onToggleChange={() => setAndPersistIsCompactModeActive( !isCompactModeActive, - currentView, + currentViewWithCombinedFiltersAndSorts, ) } toggled={isCompactModeActive} diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts index 6d91fe33b566..7dbe87226a01 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts @@ -6,6 +6,7 @@ import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { isDefined } from '~/utils/isDefined'; import { useFindManyParams } from '../../hooks/useLoadRecordIndexTable'; @@ -52,7 +53,7 @@ export const generateCsv: GenerateExport = ({ return hasSubFields; }); - if (fieldsWithSubFields) { + if (isDefined(fieldsWithSubFields)) { const nestedFieldsWithoutTypename = Object.keys( (fieldsWithSubFields as any)[column.field], ) @@ -107,15 +108,41 @@ export const useExportTableData = ({ const [pageCount, setPageCount] = useState(0); const [progress, setProgress] = useState(undefined); const [hasNextPage, setHasNextPage] = useState(true); - const { getVisibleTableColumnsSelector } = + + const { visibleTableColumnsSelector, selectedRowIdsSelector } = useRecordTableStates(recordIndexId); - const columns = useRecoilValue(getVisibleTableColumnsSelector()); - const params = useFindManyParams(objectNameSingular); + + const columns = useRecoilValue(visibleTableColumnsSelector()); + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); + + const hasSelectedRows = selectedRowIds.length > 0; + + const findManyRecordsParams = useFindManyParams( + objectNameSingular, + recordIndexId, + ); + + const selectedFindManyParams = { + ...findManyRecordsParams, + filter: { + ...findManyRecordsParams.filter, + id: { + in: selectedRowIds, + }, + }, + }; + + const usedFindManyParams = hasSelectedRows + ? selectedFindManyParams + : findManyRecordsParams; + + // Todo: this needs to be done on click on the Export not button, not to be reactive. Use Lazy query for example const { totalCount, records, fetchMoreRecords } = useFindManyRecords({ - ...params, + ...usedFindManyParams, + depth: 0, limit: pageSize, - onCompleted: (_data, { hasNextPage }) => { - setHasNextPage(hasNextPage ?? false); + onCompleted: (_data, options) => { + setHasNextPage(options?.pageInfo?.hasNextPage ?? false); }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts index 0b53cbd7fc6b..9e1c2b28b6ed 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard.ts @@ -2,16 +2,16 @@ import { useCallback, useMemo } from 'react'; import { OnDragEndResponder } from '@hello-pangea/dnd'; import { useRecoilState } from 'recoil'; -import { mapBoardFieldDefinitionsToViewFields } from '@/companies/utils/mapBoardFieldDefinitionsToViewFields'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; -import { useViewFields } from '@/views/hooks/internal/useViewFields'; -import { useViews } from '@/views/hooks/internal/useViews'; +import { useHandleViews } from '@/views/hooks/useHandleViews'; +import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields'; import { GraphQLView } from '@/views/types/GraphQLView'; +import { mapBoardFieldDefinitionsToViewFields } from '@/views/utils/mapBoardFieldDefinitionsToViewFields'; import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; @@ -30,15 +30,15 @@ export const useRecordIndexOptionsForBoard = ({ const [recordIndexFieldDefinitions, setRecordIndexFieldDefinitions] = useRecoilState(recordIndexFieldDefinitionsState); - const { persistViewFields } = useViewFields(viewBarId); - const { updateView } = useViews(viewBarId); - const { getIsCompactModeActiveState } = useRecordBoard(recordBoardId); + const { saveViewFields } = useSaveCurrentViewFields(viewBarId); + const { updateCurrentView } = useHandleViews(viewBarId); + const { isCompactModeActiveState } = useRecordBoard(recordBoardId); const [isCompactModeActive, setIsCompactModeActive] = useRecoilState( - getIsCompactModeActiveState(), + isCompactModeActiveState, ); - const { objectMetadataItem } = useObjectMetadataItemOnly({ + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -105,20 +105,14 @@ export const useRecordIndexOptionsForBoard = ({ if (isDeeplyEqual(visibleBoardFields, reorderedVisibleBoardFields)) return; - const updatedFields = [ - ...reorderedVisibleBoardFields, - ...hiddenBoardFields, - ].map((field, index) => ({ ...field, position: index })); + const updatedFields = [...reorderedVisibleBoardFields].map( + (field, index) => ({ ...field, position: index }), + ); setRecordIndexFieldDefinitions(updatedFields); - persistViewFields(mapBoardFieldDefinitionsToViewFields(updatedFields)); + saveViewFields(mapBoardFieldDefinitionsToViewFields(updatedFields)); }, - [ - hiddenBoardFields, - persistViewFields, - setRecordIndexFieldDefinitions, - visibleBoardFields, - ], + [saveViewFields, setRecordIndexFieldDefinitions, visibleBoardFields], ); // Todo : this seems over complex and should at least be extracted to an util with unit test. @@ -153,7 +147,7 @@ export const useRecordIndexOptionsForBoard = ({ ...recordIndexFieldDefinitions, { ...correspondingFieldDefinition, - position: lastVisibleBoardField.position + 1, + position: (lastVisibleBoardField?.position || 0) + 1, isVisible: true, }, ]; @@ -172,14 +166,14 @@ export const useRecordIndexOptionsForBoard = ({ setRecordIndexFieldDefinitions(updatedFieldsDefinitions); - persistViewFields( + saveViewFields( mapBoardFieldDefinitionsToViewFields(updatedFieldsDefinitions), ); }, [ recordIndexFieldDefinitionsByKey, setRecordIndexFieldDefinitions, - persistViewFields, + saveViewFields, availableColumnDefinitions, visibleBoardFields, recordIndexFieldDefinitions, @@ -190,12 +184,11 @@ export const useRecordIndexOptionsForBoard = ({ (isCompactModeActive: boolean, view: GraphQLView | undefined) => { if (!view) return; setIsCompactModeActive(isCompactModeActive); - updateView({ - ...view, + updateCurrentView({ isCompact: isCompactModeActive, }); }, - [setIsCompactModeActive, updateView], + [setIsCompactModeActive, updateCurrentView], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts index 011c6f87d42d..e72bc7534306 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useRecordIndexOptionsForTable.ts @@ -7,11 +7,11 @@ import { useTableColumns } from '@/object-record/record-table/hooks/useTableColu import { moveArrayItem } from '~/utils/array/moveArrayItem'; export const useRecordIndexOptionsForTable = (recordTableId: string) => { - const { getHiddenTableColumnsSelector, getVisibleTableColumnsSelector } = + const { hiddenTableColumnsSelector, visibleTableColumnsSelector } = useRecordTableStates(recordTableId); - const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); + const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector()); + const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); const { handleColumnVisibilityChange, handleColumnReorder } = useTableColumns( { recordTableId: recordTableId }, diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFieldDefinitionsState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFieldDefinitionsState.ts index 9fccbe7ab14e..1b260d3c9c20 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFieldDefinitionsState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFieldDefinitionsState.ts @@ -1,11 +1,11 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; -export const recordIndexFieldDefinitionsState = atom< +export const recordIndexFieldDefinitionsState = createState< ColumnDefinition[] >({ key: 'recordIndexFieldDefinitionsState', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFiltersState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFiltersState.ts index 83349211e6bd..fc3462109230 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFiltersState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexFiltersState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; -export const recordIndexFiltersState = atom({ +export const recordIndexFiltersState = createState({ key: 'recordIndexFiltersState', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexIsCompactModeActiveState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexIsCompactModeActiveState.ts index e0fba8940479..cd1d83fbb89f 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexIsCompactModeActiveState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexIsCompactModeActiveState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const recordIndexIsCompactModeActiveState = atom({ +export const recordIndexIsCompactModeActiveState = createState({ key: 'recordIndexIsCompactModeActiveState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState.ts new file mode 100644 index 000000000000..f1527934d48c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState.ts @@ -0,0 +1,8 @@ +import { createState } from 'twenty-ui'; + +export const recordIndexKanbanFieldMetadataIdState = createState( + { + key: 'recordIndexKanbanFieldMetadataIdState', + defaultValue: null, + }, +); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexSortsState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexSortsState.ts index fe98a2f6181f..57088c8e5670 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexSortsState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexSortsState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { Sort } from '@/object-record/object-sort-dropdown/types/Sort'; -export const recordIndexSortsState = atom({ +export const recordIndexSortsState = createState({ key: 'recordIndexSortsState', - default: [], + defaultValue: [], }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexViewTypeState.ts b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexViewTypeState.ts index 28dcdef038af..49b032c860d1 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexViewTypeState.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/states/recordIndexViewTypeState.ts @@ -1,8 +1,8 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; import { ViewType } from '@/views/types/ViewType'; -export const recordIndexViewTypeState = atom({ +export const recordIndexViewTypeState = createState({ key: 'recordIndexViewTypeState', - default: undefined, + defaultValue: undefined, }); diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx index fd677eecdcab..cddd82ddb07d 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCell.tsx @@ -15,7 +15,11 @@ import { useInlineCell } from '../hooks/useInlineCell'; import { RecordInlineCellContainer } from './RecordInlineCellContainer'; -export const RecordInlineCell = () => { +type RecordInlineCellProps = { + readonly?: boolean; +}; + +export const RecordInlineCell = ({ readonly }: RecordInlineCellProps) => { const { fieldDefinition, entityId } = useContext(FieldContext); const buttonIcon = useGetButtonIcon(); @@ -63,6 +67,7 @@ export const RecordInlineCell = () => { return ( { onTab={handleTab} onShiftTab={handleShiftTab} onClickOutside={handleClickOutside} + isReadOnly={readonly} /> } displayModeContent={} diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx index 51580250267f..64b349301c3c 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx @@ -1,6 +1,6 @@ import { useContext, useState } from 'react'; import { Tooltip } from 'react-tooltip'; -import { useTheme } from '@emotion/react'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; @@ -52,11 +52,16 @@ const StyledEditButtonContainer = styled(motion.div)` display: flex; `; -const StyledClickableContainer = styled.div` - cursor: pointer; +const StyledClickableContainer = styled.div<{ readonly?: boolean }>` display: flex; gap: ${({ theme }) => theme.spacing(1)}; width: 100%; + + ${({ readonly }) => + !readonly && + css` + cursor: pointer; + `}; `; const StyledInlineCellBaseContainer = styled.div` @@ -83,6 +88,7 @@ const StyledTooltip = styled(Tooltip)` `; type RecordInlineCellContainerProps = { + readonly?: boolean; IconLabel?: IconComponent; label?: string; labelWidth?: number; @@ -98,6 +104,7 @@ type RecordInlineCellContainerProps = { }; export const RecordInlineCellContainer = ({ + readonly, IconLabel, label, labelWidth, @@ -115,17 +122,21 @@ export const RecordInlineCellContainer = ({ const [isHovered, setIsHovered] = useState(false); const handleContainerMouseEnter = () => { - setIsHovered(true); + if (!readonly) { + setIsHovered(true); + } }; const handleContainerMouseLeave = () => { - setIsHovered(false); + if (!readonly) { + setIsHovered(false); + } }; const { isInlineCellInEditMode, openInlineCell } = useInlineCell(); const handleDisplayModeClick = () => { - if (!editModeContentOnly) { + if (!readonly && !editModeContentOnly) { openInlineCell(customEditHotkeyScope); } }; @@ -167,10 +178,10 @@ export const RecordInlineCellContainer = ({ )} - {isInlineCellInEditMode ? ( + {!readonly && isInlineCellInEditMode ? ( {editModeContent} ) : editModeContentOnly ? ( - + ) : ( - + theme.spacing(1)}; ${(props) => { - if (props.isHovered) { + if (props.isHovered === true) { return css` background-color: ${!props.disableHoverEffect ? props.theme.background.transparent.light diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts b/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts index 8f834fdbaadc..7ff151b03d1d 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/hooks/useInlineCell.ts @@ -5,6 +5,7 @@ import { FieldContext } from '@/object-record/record-field/contexts/FieldContext import { useRecordFieldInput } from '@/object-record/record-field/hooks/useRecordFieldInput'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { isDefined } from '~/utils/isDefined'; import { isInlineCellInEditModeScopedState } from '../states/isInlineCellInEditModeScopedState'; import { InlineCellHotkeyScope } from '../types/InlineCellHotkeyScope'; @@ -39,7 +40,7 @@ export const useInlineCell = () => { setIsInlineCellInEditMode(true); initFieldInputDraftValue(); - if (customEditHotkeyScopeForField) { + if (isDefined(customEditHotkeyScopeForField)) { setHotkeyScopeAndMemorizePreviousScope( customEditHotkeyScopeForField.scope, customEditHotkeyScopeForField.customScopes, diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/states/customEditHotkeyScopeForFieldScopedState.ts b/packages/twenty-front/src/modules/object-record/record-inline-cell/states/customEditHotkeyScopeForFieldScopedState.ts index 37abc8db1565..96db47ff940d 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/states/customEditHotkeyScopeForFieldScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/states/customEditHotkeyScopeForFieldScopedState.ts @@ -1,11 +1,10 @@ -import { atomFamily } from 'recoil'; - import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const customEditHotkeyScopeForFieldScopedState = atomFamily< +export const customEditHotkeyScopeForFieldScopedState = createFamilyState< HotkeyScope | null, string >({ key: 'customEditHotkeyScopeForFieldScopedState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState.ts b/packages/twenty-front/src/modules/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState.ts index bdc57ccb36a9..9142d9400e02 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/states/isInlineCellInEditModeScopedState.ts @@ -1,6 +1,9 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const isInlineCellInEditModeScopedState = atomFamily({ +export const isInlineCellInEditModeScopedState = createFamilyState< + boolean, + string +>({ key: 'isInlineCellInEditModeScopedState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/states/parentHotkeyScopeForFieldScopedState.ts b/packages/twenty-front/src/modules/object-record/record-inline-cell/states/parentHotkeyScopeForFieldScopedState.ts index 0314469079d7..4562c09cfa9a 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/states/parentHotkeyScopeForFieldScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/states/parentHotkeyScopeForFieldScopedState.ts @@ -1,11 +1,10 @@ -import { atomFamily } from 'recoil'; - import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const parentHotkeyScopeForFieldScopedState = atomFamily< +export const parentHotkeyScopeForFieldScopedState = createFamilyState< HotkeyScope | null, string >({ key: 'parentHotkeyScopeForFieldScopedState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx index 98f4e4f13b35..39c8f6c103f0 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx @@ -1,9 +1,9 @@ +import groupBy from 'lodash.groupby'; import { useRecoilState, useRecoilValue } from 'recoil'; +import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; -import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation'; -import { parseFieldType } from '@/object-metadata/utils/parseFieldType'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { FieldContext, @@ -18,7 +18,7 @@ import { RecordDetailRelationSection } from '@/object-record/record-show/record- import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierSelector'; -import { isFieldMetadataItemAvailable } from '@/object-record/utils/isFieldMetadataItemAvailable'; +import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; import { ShowPageContainer } from '@/ui/layout/page/ShowPageContainer'; import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer'; import { ShowPageRightContainer } from '@/ui/layout/show-page/components/ShowPageRightContainer'; @@ -30,7 +30,8 @@ import { FileFolder, useUploadImageMutation, } from '~/generated/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; type RecordShowContainerProps = { objectNameSingular: string; @@ -41,8 +42,12 @@ export const RecordShowContainer = ({ objectNameSingular, objectRecordId, }: RecordShowContainerProps) => { - const { objectMetadataItem, labelIdentifierFieldMetadata } = - useObjectMetadataItem({ + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { labelIdentifierFieldMetadataItem } = + useLabelIdentifierFieldMetadataItem({ objectNameSingular, }); @@ -89,13 +94,7 @@ export const RecordShowContainer = ({ const avatarUrl = result?.data?.uploadImage; - if (!avatarUrl) { - return; - } - if (!updateOneRecord) { - return; - } - if (!recordFromStore) { + if (!avatarUrl || isUndefinedOrNull(updateOneRecord) || !recordFromStore) { return; } @@ -110,28 +109,26 @@ export const RecordShowContainer = ({ const availableFieldMetadataItems = objectMetadataItem.fields .filter( (fieldMetadataItem) => - isFieldMetadataItemAvailable(fieldMetadataItem) && - fieldMetadataItem.id !== labelIdentifierFieldMetadata?.id, + isFieldCellSupported(fieldMetadataItem) && + fieldMetadataItem.id !== labelIdentifierFieldMetadataItem?.id, ) .sort((fieldMetadataItemA, fieldMetadataItemB) => fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), ); - const inlineFieldMetadataItems = availableFieldMetadataItems.filter( + const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy( + availableFieldMetadataItems, (fieldMetadataItem) => - fieldMetadataItem.type !== FieldMetadataType.Relation, - ); - - const relationFieldMetadataItems = availableFieldMetadataItems.filter( - (fieldMetadataItem) => - fieldMetadataItem.type === FieldMetadataType.Relation, + fieldMetadataItem.type === FieldMetadataType.Relation + ? 'relationFieldMetadataItems' + : 'inlineFieldMetadataItems', ); return ( - {!recordLoading && isNonNullable(recordFromStore) && ( + {!recordLoading && isDefined(recordFromStore) && ( <> - {relationFieldMetadataItems - .filter((item) => { - const relationObjectMetadataItem = item.toRelationMetadata - ? item.toRelationMetadata.fromObjectMetadata - : item.fromRelationMetadata?.toObjectMetadata; - - if (!relationObjectMetadataItem) { - return false; - } - - return isObjectMetadataAvailableForRelation( - relationObjectMetadataItem, - ); - }) - .map((fieldMetadataItem, index) => ( - - - - ))} + {relationFieldMetadataItems?.map((fieldMetadataItem, index) => ( + + + + ))} )} diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainerEffect.tsx index b3f660e402b1..0f6246887600 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainerEffect.tsx @@ -1,12 +1,11 @@ import { useEffect } from 'react'; import { useRecoilState, useSetRecoilState } from 'recoil'; -import { useActivityConnectionUtils } from '@/activities/hooks/useActivityConnectionUtils'; import { Activity } from '@/activities/types/Activity'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const RecordShowContainer = ({ objectRecordId, @@ -15,7 +14,7 @@ export const RecordShowContainer = ({ objectRecordId: string; objectNameSingular: string; }) => { - const { record, loading } = useFindOneRecord({ + const { record: activity, loading } = useFindOneRecord({ objectRecordId, objectNameSingular, depth: 3, @@ -35,14 +34,9 @@ export const RecordShowContainer = ({ } }, [loading, recordLoading, setRecordLoading]); - const { makeActivityWithoutConnection } = useActivityConnectionUtils(); - useEffect(() => { - if (!loading && isNonNullable(record)) { - const { activity: activityWithoutConnection } = - makeActivityWithoutConnection(record as any); - - setRecordStore(activityWithoutConnection as Activity); + if (!loading && isDefined(activity)) { + setRecordStore(activity); } - }, [loading, record, setRecordStore, makeActivityWithoutConnection]); + }, [loading, setRecordStore, activity]); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx index d18c51bec95a..0317df93006b 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRecordsList.tsx @@ -2,7 +2,6 @@ import styled from '@emotion/styled'; const StyledRecordsList = styled.div` color: ${({ theme }) => theme.font.color.secondary}; - overflow: hidden; `; export { StyledRecordsList as RecordDetailRecordsList }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx index 0c2df8ae8d3f..c8ce45c0b9ad 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsList.tsx @@ -1,3 +1,5 @@ +import { useState } from 'react'; + import { RecordDetailRecordsList } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsList'; import { RecordDetailRelationRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; @@ -8,13 +10,22 @@ type RecordDetailRelationRecordsListProps = { export const RecordDetailRelationRecordsList = ({ relationRecords, -}: RecordDetailRelationRecordsListProps) => ( - - {relationRecords.slice(0, 5).map((relationRecord) => ( - - ))} - -); +}: RecordDetailRelationRecordsListProps) => { + const [expandedItem, setExpandedItem] = useState(''); + + const handleItemClick = (recordId: string) => + setExpandedItem(recordId === expandedItem ? '' : recordId); + + return ( + + {relationRecords.slice(0, 5).map((relationRecord) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx index dd801414b682..fd46315aeaa5 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationRecordsListItem.tsx @@ -1,23 +1,43 @@ -import { useContext } from 'react'; +import { useCallback, useContext } from 'react'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; -import { LightIconButton, MenuItem } from 'tsup.ui.index'; +import { motion } from 'framer-motion'; +import { + IconChevronDown, + IconDotsVertical, + IconTrash, + IconUnlink, +} from 'twenty-ui'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; import { RecordChip } from '@/object-record/components/RecordChip'; import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord.ts'; +import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { + FieldContext, + RecordUpdateHook, + RecordUpdateHookParams, +} from '@/object-record/record-field/contexts/FieldContext'; import { usePersistField } from '@/object-record/record-field/hooks/usePersistField'; import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { RecordInlineCell } from '@/object-record/record-inline-cell/components/RecordInlineCell'; +import { PropertyBox } from '@/object-record/record-inline-cell/property-box/components/PropertyBox'; +import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/InlineCellHotkeyScope'; import { RecordDetailRecordsListItem } from '@/object-record/record-show/record-detail-section/components/RecordDetailRecordsListItem'; +import { useSetRecordInStore } from '@/object-record/record-store/hooks/useSetRecordInStore'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { IconDotsVertical, IconTrash, IconUnlink } from '@/ui/display/icon'; +import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; +import { IconComponent } from '@/ui/display/icon/types/IconComponent'; +import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; 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 { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope'; +import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; const StyledListItem = styled(RecordDetailRecordsListItem)<{ isDropdownOpen?: boolean; @@ -40,11 +60,26 @@ const StyledListItem = styled(RecordDetailRecordsListItem)<{ } `; +const StyledClickableZone = styled.div` + align-items: center; + cursor: pointer; + display: flex; + flex: 1 0 auto; + height: 100%; + justify-content: flex-end; +`; + +const MotionIconChevronDown = motion(IconChevronDown); + type RecordDetailRelationRecordsListItemProps = { + isExpanded: boolean; + onClick: (relationRecordId: string) => void; relationRecord: ObjectRecord; }; export const RecordDetailRelationRecordsListItem = ({ + isExpanded, + onClick, relationRecord, }: RecordDetailRelationRecordsListItemProps) => { const { fieldDefinition } = useContext(FieldContext); @@ -53,7 +88,6 @@ export const RecordDetailRelationRecordsListItem = ({ relationFieldMetadataId, relationObjectMetadataNameSingular, relationType, - objectMetadataNameSingular, } = fieldDefinition.metadata as FieldRelationMetadata; const isToOneObject = relationType === 'TO_ONE_OBJECT'; @@ -61,15 +95,36 @@ export const RecordDetailRelationRecordsListItem = ({ useObjectMetadataItem({ objectNameSingular: relationObjectMetadataNameSingular, }); + const persistField = usePersistField(); + + const { + called: hasFetchedRelationRecord, + findOneRecord: findOneRelationRecord, + } = useLazyFindOneRecord({ + objectNameSingular: relationObjectMetadataNameSingular, + }); const { updateOneRecord: updateOneRelationRecord } = useUpdateOneRecord({ objectNameSingular: relationObjectMetadataNameSingular, }); - const { deleteOneRecord: deleteOneRelationRecord } = useDeleteOneRecord({ objectNameSingular: relationObjectMetadataNameSingular, }); + const isAccountOwnerRelation = + relationObjectMetadataNameSingular === + CoreObjectNameSingular.WorkspaceMember; + + const availableRelationFieldMetadataItems = relationObjectMetadataItem.fields + .filter( + (fieldMetadataItem) => + isFieldCellSupported(fieldMetadataItem) && + fieldMetadataItem.id !== + relationObjectMetadataItem.labelIdentifierFieldMetadataId && + fieldMetadataItem.id !== relationFieldMetadataId, + ) + .sort(); + const dropdownScopeId = `record-field-card-menu-${relationRecord.id}`; const { closeDropdown, isDropdownOpen } = useDropdown(dropdownScopeId); @@ -101,25 +156,58 @@ export const RecordDetailRelationRecordsListItem = ({ await deleteOneRelationRecord(relationRecord.id); }; - const isOpportunityCompanyRelation = - (objectMetadataNameSingular === CoreObjectNameSingular.Opportunity && - relationObjectMetadataNameSingular === CoreObjectNameSingular.Company) || - (objectMetadataNameSingular === CoreObjectNameSingular.Company && - relationObjectMetadataNameSingular === - CoreObjectNameSingular.Opportunity); + const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { + const updateEntity = ({ variables }: RecordUpdateHookParams) => { + updateOneRelationRecord?.({ + idToUpdate: variables.where.id as string, + updateOneRecordInput: variables.updateOneRecordInput, + }); + }; - const isAccountOwnerRelation = - relationObjectMetadataNameSingular === - CoreObjectNameSingular.WorkspaceMember; + return [updateEntity, { loading: false }]; + }; - return ( - - onClick(relationRecord.id); + + const AnimatedIconChevronDown = useCallback( + (props) => ( + - {/* TODO: temporary to prevent removing a company from an opportunity */} - {!isOpportunityCompanyRelation && ( + ), + [isExpanded], + ); + + return ( + <> + + + + !hasFetchedRelationRecord && + findOneRelationRecord({ + objectRecordId: relationRecord.id, + onCompleted: (record) => setRecords([record]), + }) + } + > + + } - dropdownHotkeyScope={{ - scope: dropdownScopeId, - }} + dropdownHotkeyScope={{ scope: dropdownScopeId }} /> - )} - + + + + {availableRelationFieldMetadataItems.map( + (fieldMetadataItem, index) => ( + + + + ), + )} + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx index c3c2133377dd..7a3252e7d82d 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailRelationSection.tsx @@ -2,6 +2,7 @@ import { useCallback, useContext } from 'react'; import styled from '@emotion/styled'; import qs from 'qs'; import { useRecoilValue } from 'recoil'; +import { IconForbid, IconPencil, IconPlus } from 'twenty-ui'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; @@ -19,12 +20,11 @@ import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRela import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { IconForbid, IconPencil, IconPlus } from '@/ui/display/icon'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { DropdownScope } from '@/ui/layout/dropdown/scopes/DropdownScope'; -import { FilterQueryParams } from '@/views/hooks/internal/useFiltersFromQueryParams'; +import { FilterQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; const StyledAddDropdown = styled(Dropdown)` @@ -51,16 +51,17 @@ export const RecordDetailRelationSection = () => { ); const fieldValue = useRecoilValue< - ({ id: string } & Record) | null + ({ id: string } & Record) | ObjectRecord[] | null >(recordStoreFamilySelector({ recordId: entityId, fieldName })); + // TODO: use new relation type const isToOneObject = relationType === 'TO_ONE_OBJECT'; const isFromManyObjects = relationType === 'FROM_MANY_OBJECTS'; const relationRecords: ObjectRecord[] = fieldValue && isToOneObject - ? [fieldValue] - : fieldValue?.edges.map(({ node }: { node: ObjectRecord }) => node) ?? []; + ? [fieldValue as ObjectRecord] + : (fieldValue as ObjectRecord[]) ?? []; const relationRecordIds = relationRecords.map(({ id }) => id); const dropdownId = `record-field-card-relation-picker-${fieldDefinition.label}`; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSection.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSection.tsx index 8a9218ae9a41..9d1e29b10e52 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSection.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSection.tsx @@ -5,6 +5,7 @@ import { Section } from '@/ui/layout/section/components/Section'; const StyledRecordDetailSection = styled(Section)` border-top: 1px solid ${({ theme }) => theme.border.color.light}; padding: ${({ theme }) => theme.spacing(3)}; + padding-right: ${({ theme }) => theme.spacing(2)}; width: auto; `; diff --git a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx index f01d11777c97..b9087184b92a 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/record-detail-section/components/RecordDetailSectionHeader.tsx @@ -53,7 +53,7 @@ export const RecordDetailSectionHeader = ({ {title} {link && {link.label}} - {hideRightAdornmentOnMouseLeave && !isHovered! ? null : rightAdornment} + {hideRightAdornmentOnMouseLeave && !isHovered ? null : rightAdornment} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts b/packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts index fb3f09a1c9c3..832d7ff11af3 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/hooks/useSetRecordInStore.ts @@ -10,7 +10,7 @@ export const useSetRecordInStore = () => { records.forEach((record) => { const currentRecord = snapshot .getLoadable(recordStoreFamilyState(record.id)) - .valueOrThrow(); + .getValue(); if (JSON.stringify(currentRecord) !== JSON.stringify(record)) { set(recordStoreFamilyState(record.id), record); diff --git a/packages/twenty-front/src/modules/object-record/record-store/states/recordLoadingFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-store/states/recordLoadingFamilyState.ts index 68a97545e46f..cbfd3ebb0211 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/states/recordLoadingFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/states/recordLoadingFamilyState.ts @@ -1,6 +1,6 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const recordLoadingFamilyState = atomFamily({ +export const recordLoadingFamilyState = createFamilyState({ key: 'recordLoadingFamilyState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-store/states/recordStoreFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-store/states/recordStoreFamilyState.ts index a0e71f0bb09d..9c7877004c7a 100644 --- a/packages/twenty-front/src/modules/object-record/record-store/states/recordStoreFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/record-store/states/recordStoreFamilyState.ts @@ -1,8 +1,10 @@ -import { atomFamily } from 'recoil'; - import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const recordStoreFamilyState = atomFamily({ +export const recordStoreFamilyState = createFamilyState< + ObjectRecord | null, + string +>({ key: 'recordStoreFamilyState', - default: null, + defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx b/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx index 86b5e3534b2c..f7241a5e2a48 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/action-bar/components/RecordTableActionBar.tsx @@ -8,13 +8,13 @@ export const RecordTableActionBar = ({ }: { recordTableId: string; }) => { - const { getSelectedRowIdsSelector } = useRecordTableStates(recordTableId); + const { selectedRowIdsSelector } = useRecordTableStates(recordTableId); - const selectedRowIds = useRecoilValue(getSelectedRowIdsSelector()); + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); if (!selectedRowIds.length) { return null; } - return ; + return ; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/CheckboxCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/CheckboxCell.tsx index 74af8f6ab5a9..2aff77e1b162 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/CheckboxCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/CheckboxCell.tsx @@ -16,6 +16,7 @@ const StyledContainer = styled.div` height: 32px; justify-content: center; + background-color: ${({ theme }) => theme.background.primary}; `; export const CheckboxCell = () => { diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/ColumnHead.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/ColumnHead.tsx index f9678b4111ee..5f3207bfa27e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/ColumnHead.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/ColumnHead.tsx @@ -1,8 +1,11 @@ -import { useTheme } from '@emotion/react'; +import { css, useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; +import { MOBILE_VIEWPORT } from '@/ui/theme/constants/MobileViewport'; +import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState'; import { ColumnDefinition } from '../types/ColumnDefinition'; @@ -10,7 +13,7 @@ type ColumnHeadProps = { column: ColumnDefinition; }; -const StyledTitle = styled.div` +const StyledTitle = styled.div<{ hideTitle?: boolean }>` align-items: center; display: flex; flex-direction: row; @@ -19,6 +22,14 @@ const StyledTitle = styled.div` height: ${({ theme }) => theme.spacing(8)}; padding-left: ${({ theme }) => theme.spacing(2)}; padding-right: ${({ theme }) => theme.spacing(2)}; + + ${({ hideTitle }) => + hideTitle && + css` + @media (max-width: ${MOBILE_VIEWPORT}px) { + display: none; + } + `} `; const StyledIcon = styled.div` @@ -42,8 +53,10 @@ export const ColumnHead = ({ column }: ColumnHeadProps) => { const { getIcon } = useIcons(); const Icon = getIcon(column.iconName); + const scrollLeft = useRecoilValue(scrollLeftState); + return ( - + 0}> diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx index 07638f0fa49c..aeeae2897ffe 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTable.tsx @@ -1,17 +1,23 @@ -import { useRef } from 'react'; +import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; -import { useObjectMetadataItemOnly } from '@/object-metadata/hooks/useObjectMetadataItemOnly'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { RecordTableBody } from '@/object-record/record-table/components/RecordTableBody'; import { RecordTableBodyEffect } from '@/object-record/record-table/components/RecordTableBodyEffect'; -import { RecordTableFirstColumnScrollEffect } from '@/object-record/record-table/components/RecordTableFirstColumnScrollObserver'; import { RecordTableHeader } from '@/object-record/record-table/components/RecordTableHeader'; import { RecordTableContext } from '@/object-record/record-table/contexts/RecordTableContext'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope'; +import { MOBILE_VIEWPORT } from '@/ui/theme/constants/MobileViewport'; import { RGBA } from '@/ui/theme/constants/Rgba'; +import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState'; +import { scrollTopState } from '@/ui/utilities/scroll/states/scrollTopState'; -const StyledTable = styled.table` +const StyledTable = styled.table<{ + freezeFirstColumns?: boolean; + freezeHeaders?: boolean; +}>` border-radius: ${({ theme }) => theme.border.radius.sm}; border-spacing: 0; margin-right: ${({ theme }) => theme.table.horizontalCellMargin}; @@ -63,33 +69,53 @@ const StyledTable = styled.table` border-right: none; } - thead th:nth-of-type(1), tbody td:nth-of-type(1) { left: 0; } - thead th:nth-of-type(2), - tbody td:nth-of-type(2) { - left: calc(${({ theme }) => theme.table.checkboxColumnWidth} - 2px); + + // Label identifier column + thead th:nth-of-type(1), + thead th:nth-of-type(2) { + left: 0; + top: 0; + z-index: 6; } - tbody td:nth-of-type(2)::after, - thead th:nth-of-type(2)::after { - content: ''; - height: calc(100% + 1px); - position: absolute; + thead th:nth-of-type(n + 3) { top: 0; - width: 4px; - right: -4px; + z-index: 5; + position: sticky; } - &.freeze-first-columns-shadow thead th:nth-of-type(2)::after, - &.freeze-first-columns-shadow tbody td:nth-of-type(2)::after { - box-shadow: ${({ theme }) => - `4px 0px 4px -4px ${ - theme.name === 'dark' - ? RGBA(theme.grayScale.gray50, 0.8) - : RGBA(theme.grayScale.gray100, 0.25) - } inset`}; + thead th:nth-of-type(2), + tbody td:nth-of-type(2) { + left: calc(${({ theme }) => theme.table.checkboxColumnWidth} - 2px); + + ${({ freezeFirstColumns }) => + freezeFirstColumns && + css` + @media (max-width: ${MOBILE_VIEWPORT}px) { + width: 35px; + max-width: 35px; + } + `} + + &::after { + content: ''; + height: calc(100% + 1px); + position: absolute; + width: 4px; + right: -4px; + top: 0; + + ${({ freezeFirstColumns, theme }) => + freezeFirstColumns && + css` + box-shadow: 4px 0px 4px -4px ${theme.name === 'dark' + ? RGBA(theme.grayScale.gray50, 0.8) + : RGBA(theme.grayScale.gray100, 0.25)} inset; + `} + } } thead th:nth-of-type(3), @@ -111,10 +137,11 @@ export const RecordTable = ({ onColumnsChange, createRecord, }: RecordTableProps) => { - const recordTableRef = useRef(null); const { scopeId } = useRecordTableStates(recordTableId); + const scrollLeft = useRecoilValue(scrollLeftState); + const scrollTop = useRecoilValue(scrollTopState); - const { objectMetadataItem } = useObjectMetadataItemOnly({ + const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -127,11 +154,13 @@ export const RecordTable = ({ - - + 0} + freezeHeaders={scrollTop > 0} + className="entity-table-cell" + > diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx index 4ba288f2282f..3947067f4806 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableBody.tsx @@ -1,6 +1,7 @@ import { useRecoilValue } from 'recoil'; import { RecordTableBodyFetchMoreLoader } from '@/object-record/record-table/components/RecordTableBodyFetchMoreLoader'; +import { RecordTablePendingRow } from '@/object-record/record-table/components/RecordTablePendingRow'; import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; @@ -11,13 +12,14 @@ type RecordTableBodyProps = { export const RecordTableBody = ({ objectNameSingular, }: RecordTableBodyProps) => { - const { getTableRowIdsState } = useRecordTableStates(); + const { tableRowIdsState } = useRecordTableStates(); - const tableRowIds = useRecoilValue(getTableRowIdsState()); + const tableRowIds = useRecoilValue(tableRowIdsState); return ( <> + {tableRowIds.map((recordId, rowIndex) => ( ` - background: ${({ isSelected, theme }) => - isSelected ? theme.accent.quaternary : theme.background.primary}; -`; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; export const RecordTableCellContainer = () => { - const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); - const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); - - const { setCurrentRowSelected } = useSetCurrentRowSelected(); - - const handleContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - setCurrentRowSelected(true); - setContextMenuPosition({ - x: event.clientX, - y: event.clientY, - }); - setContextMenuOpenState(true); - }; - const { objectMetadataItem } = useContext(RecordTableContext); const { columnDefinition } = useContext(RecordTableCellContext); - const { recordId, pathToShowPage, isSelected } = useContext( - RecordTableRowContext, - ); + const { recordId, pathToShowPage } = useContext(RecordTableRowContext); const updateRecord = useContext(RecordUpdateContext); - if (!columnDefinition) { + if (isUndefinedOrNull(columnDefinition)) { return null; } @@ -54,29 +28,24 @@ export const RecordTableCellContainer = () => { : TableHotkeyScope.CellEditMode; return ( - handleContextMenu(event)} + [updateRecord, {}], + hotkeyScope: customHotkeyScope, + basePathToShowPage: pathToShowPage, + isLabelIdentifier: isLabelIdentifierField({ + fieldMetadataItem: { + id: columnDefinition.fieldMetadataId, + name: columnDefinition.metadata.fieldName, + }, + objectMetadataItem, + }), + }} > - [updateRecord, {}], - hotkeyScope: customHotkeyScope, - basePathToShowPage: pathToShowPage, - isLabelIdentifier: isLabelIdentifierField({ - fieldMetadataItem: { - id: columnDefinition.fieldMetadataId, - name: columnDefinition.metadata.fieldName, - }, - objectMetadataItem, - }), - }} - > - - - + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableColumnDropdownMenu.tsx index 911bb3d87ff7..96a257041fe1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableColumnDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableColumnDropdownMenu.tsx @@ -1,9 +1,16 @@ import { useRecoilValue } from 'recoil'; +import { + IconArrowLeft, + IconArrowRight, + IconEyeOff, + IconFilter, + IconSortDescending, +} from 'twenty-ui'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { IconArrowLeft, IconArrowRight, IconEyeOff } from '@/ui/display/icon'; 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'; @@ -17,9 +24,13 @@ export type RecordTableColumnDropdownMenuProps = { export const RecordTableColumnDropdownMenu = ({ column, }: RecordTableColumnDropdownMenuProps) => { - const { getVisibleTableColumnsSelector } = useRecordTableStates(); + const { + visibleTableColumnsSelector, + onToggleColumnFilterState, + onToggleColumnSortState, + } = useRecordTableStates(); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); + const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); const secondVisibleColumn = visibleTableColumns[1]; const canMoveLeft = @@ -55,8 +66,42 @@ export const RecordTableColumnDropdownMenu = ({ handleColumnVisibilityChange(column); }; + const onToggleColumnFilter = useRecoilValue(onToggleColumnFilterState); + const onToggleColumnSort = useRecoilValue(onToggleColumnSortState); + + const handleSortClick = () => { + closeDropdown(); + + onToggleColumnSort?.(column.fieldMetadataId); + }; + + const handleFilterClick = () => { + closeDropdown(); + + onToggleColumnFilter?.(column.fieldMetadataId); + }; + + const isSortable = column.isSortable === true; + const isFilterable = column.isFilterable === true; + const showSeparator = isFilterable || isSortable; + return ( + {isFilterable && ( + + )} + {isSortable && ( + + )} + {showSeparator && } {canMoveLeft && ( { - const { recordTableRef } = useContext(RecordTableContext); - - const scrollLeft = useRecoilValue(scrollLeftState); - - useEffect(() => { - recordTableRef.current?.classList.toggle( - 'freeze-first-columns-shadow', - scrollLeft > 0, - ); - }, [scrollLeft, recordTableRef]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx index 6cde82074ca4..edc262e725be 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeader.tsx @@ -1,10 +1,10 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { IconPlus } from 'twenty-ui'; import { RecordTableHeaderCell } from '@/object-record/record-table/components/RecordTableHeaderCell'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { IconPlus } from '@/ui/display/icon'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { useScrollWrapperScopedRef } from '@/ui/utilities/scroll/hooks/useScrollWrapperScopedRef'; @@ -28,8 +28,7 @@ const StyledPlusIconHeaderCell = styled.th<{ isTableWiderThanScreen: boolean }>` min-width: 32px; ${({ isTableWiderThanScreen, theme }) => isTableWiderThanScreen && - `position: relative; - right: 0; + ` width: 32px; border-right: none !important; background-color: ${theme.background.primary}; @@ -56,16 +55,15 @@ export const RecordTableHeader = ({ }: { createRecord: () => void; }) => { - const { getHiddenTableColumnsSelector, getVisibleTableColumnsSelector } = - useRecordTableStates(); + const { visibleTableColumnsSelector } = useRecordTableStates(); const scrollWrapper = useScrollWrapperScopedRef(); const isTableWiderThanScreen = (scrollWrapper.current?.clientWidth ?? 0) < (scrollWrapper.current?.scrollWidth ?? 0); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); - const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); + const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); + const hiddenTableColumns = useRecoilValue(visibleTableColumnsSelector()); const theme = useTheme(); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderCell.tsx index a238792bd5c4..698939c85b8c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderCell.tsx @@ -1,16 +1,18 @@ import { useCallback, useMemo, useState } from 'react'; import styled from '@emotion/styled'; import { useRecoilCallback, useRecoilState, useRecoilValue } from 'recoil'; +import { IconPlus } from 'twenty-ui'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { ColumnHead } from '@/object-record/record-table/components/ColumnHead'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; -import { IconPlus } from '@/ui/display/icon'; import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { useTrackPointer } from '@/ui/utilities/pointer-event/hooks/useTrackPointer'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { scrollLeftState } from '@/ui/utilities/scroll/states/scrollLeftState'; import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; import { ColumnHeadWithDropdown } from './ColumnHeadWithDropdown'; @@ -35,7 +37,7 @@ const StyledColumnHeaderCell = styled.th<{ `; }}; ${({ isResizing, theme }) => { - if (isResizing) { + if (isResizing === true) { return `&:after { background-color: ${theme.color.blue}; bottom: 0; @@ -70,9 +72,7 @@ const StyledColumnHeadContainer = styled.div` `; const StyledHeaderIcon = styled.div` - margin-bottom: ${({ theme }) => theme.spacing(1)}; - margin-right: ${({ theme }) => theme.spacing(1)}; - margin-top: ${({ theme }) => theme.spacing(1)}; + margin: ${({ theme }) => theme.spacing(1, 1, 1, 1.5)}; `; export const RecordTableHeaderCell = ({ @@ -82,14 +82,13 @@ export const RecordTableHeaderCell = ({ column: ColumnDefinition; createRecord: () => void; }) => { - const { getResizeFieldOffsetState, getTableColumnsState } = - useRecordTableStates(); + const { resizeFieldOffsetState, tableColumnsState } = useRecordTableStates(); const [resizeFieldOffset, setResizeFieldOffset] = useRecoilState( - getResizeFieldOffsetState(), + resizeFieldOffsetState, ); - const tableColumns = useRecoilValue(getTableColumnsState()); + const tableColumns = useRecoilValue(tableColumnsState); const tableColumnsByKey = useMemo( () => mapArrayToObject(tableColumns, ({ fieldMetadataId }) => fieldMetadataId), @@ -124,7 +123,7 @@ export const RecordTableHeaderCell = ({ const resizeFieldOffset = getSnapshotValue( snapshot, - getResizeFieldOffsetState(), + resizeFieldOffsetState, ); const nextWidth = Math.round( @@ -134,7 +133,7 @@ export const RecordTableHeaderCell = ({ ), ); - set(getResizeFieldOffsetState(), 0); + set(resizeFieldOffsetState, 0); setInitialPointerPositionX(null); setResizedFieldKey(null); @@ -150,7 +149,7 @@ export const RecordTableHeaderCell = ({ }, [ resizedFieldKey, - getResizeFieldOffsetState, + resizeFieldOffsetState, tableColumnsByKey, tableColumns, handleColumnsChange, @@ -164,6 +163,12 @@ export const RecordTableHeaderCell = ({ onMouseUp: handleResizeHandlerEnd, }); + const isMobile = useIsMobile(); + const scrollLeft = useRecoilValue(scrollLeftState); + + const disableColumnResize = + column.isLabelIdentifier && isMobile && scrollLeft > 0; + return ( setIconVisibility(true)} + onMouseLeave={() => setIconVisibility(false)} > - setIconVisibility(true)} - onMouseLeave={() => setIconVisibility(false)} - > + {column.isLabelIdentifier ? ( ) : ( )} - {iconVisibility && !!column.isLabelIdentifier && ( + {(useIsMobile() || iconVisibility) && !!column.isLabelIdentifier && ( )} - { - setResizedFieldKey(column.fieldMetadataId); - }} - /> + {!disableColumnResize && ( + { + setResizedFieldKey(column.fieldMetadataId); + }} + /> + )} ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderPlusButtonContent.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderPlusButtonContent.tsx index b08140cea4a5..136a888ccb87 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderPlusButtonContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableHeaderPlusButtonContent.tsx @@ -2,12 +2,12 @@ import { useCallback } from 'react'; import { Link } from 'react-router-dom'; import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; +import { IconSettings } from 'twenty-ui'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useTableColumns } from '@/object-record/record-table/hooks/useTableColumns'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; -import { IconSettings } from '@/ui/display/icon'; import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; @@ -17,9 +17,9 @@ import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; export const RecordTableHeaderPlusButtonContent = () => { const { closeDropdown } = useDropdown(); - const { getHiddenTableColumnsSelector } = useRecordTableStates(); + const { hiddenTableColumnsSelector } = useRecordTableStates(); - const hiddenTableColumns = useRecoilValue(getHiddenTableColumnsSelector()); + const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector()); const { getIcon } = useIcons(); const { handleColumnVisibilityChange } = useTableColumns(); diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTablePendingRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTablePendingRow.tsx new file mode 100644 index 000000000000..2c05409d326e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTablePendingRow.tsx @@ -0,0 +1,19 @@ +import { useRecoilValue } from 'recoil'; + +import { RecordTableRow } from '@/object-record/record-table/components/RecordTableRow'; +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; + +export const RecordTablePendingRow = () => { + const { pendingRecordIdState } = useRecordTableStates(); + const pendingRecordId = useRecoilValue(pendingRecordIdState); + + if (!pendingRecordId) return; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx index b6af608f61c3..7b9dfc25b532 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/components/RecordTableRow.tsx @@ -23,12 +23,12 @@ const StyledTd = styled.td` `; export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { - const { getVisibleTableColumnsSelector, isRowSelectedFamilyState } = + const { visibleTableColumnsSelector, isRowSelectedFamilyState } = useRecordTableStates(); const currentRowSelected = useRecoilValue(isRowSelectedFamilyState(recordId)); const { objectMetadataItem } = useContext(RecordTableContext); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); + const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); const scrollWrapperRef = useContext(ScrollWrapperContext); @@ -43,8 +43,11 @@ export const RecordTableRow = ({ recordId, rowIndex }: RecordTableRowProps) => { recordId, rowIndex, pathToShowPage: - getBasePathToShowPage({ objectMetadataItem }) + recordId, + getBasePathToShowPage({ + objectNameSingular: objectMetadataItem.nameSingular, + }) + recordId, isSelected: currentRowSelected, + isReadOnly: objectMetadataItem.isRemote ?? false, }} > { const tableBodyRef = useRef(null); - const { getNumberOfTableRowsState, getIsRecordTableInitialLoadingState } = + const { numberOfTableRowsState, isRecordTableInitialLoadingState } = useRecordTableStates(recordTableId); - const numberOfTableRows = useRecoilValue(getNumberOfTableRowsState()); + const numberOfTableRows = useRecoilValue(numberOfTableRowsState); const isRecordTableInitialLoading = useRecoilValue( - getIsRecordTableInitialLoadingState(), + isRecordTableInitialLoadingState, ); - const { resetTableRowSelection, setRowSelectedState } = useRecordTable({ + const { resetTableRowSelection, setRowSelected } = useRecordTable({ recordTableId, }); @@ -76,11 +79,11 @@ export const RecordTableWithWrappers = ({ }, ); - const { persistViewFields } = useViewFields(viewBarId); + const { saveViewFields } = useSaveCurrentViewFields(viewBarId); const { deleteOneRecord } = useDeleteOneRecord({ objectNameSingular }); - const objectLabel = foundObjectMetadataItem?.nameSingular; + const objectLabel = foundObjectMetadataItem?.labelSingular; return ( @@ -92,17 +95,22 @@ export const RecordTableWithWrappers = ({ (columns) => { - persistViewFields( - mapColumnDefinitionsToViewFields(columns), - ); - })} + onColumnsChange={useRecoilCallback( + () => (columns) => { + saveViewFields( + mapColumnDefinitionsToViewFields( + columns as ColumnDefinition[], + ), + ); + }, + [saveViewFields], + )} createRecord={createRecord} />
theme.background.primary}; `; export const SelectAllCheckbox = () => { - const { getAllRowsSelectedStatusSelector } = useRecordTableStates(); + const { allRowsSelectedStatusSelector } = useRecordTableStates(); - const allRowsSelectedStatus = useRecoilValue( - getAllRowsSelectedStatusSelector(), - ); - const { selectAllRows } = useRecordTable(); + const allRowsSelectedStatus = useRecoilValue(allRowsSelectedStatusSelector()); + const { selectAllRows, resetTableRowSelection, setHasUserSelectedAllRows } = + useRecordTable(); const checked = allRowsSelectedStatus === 'all'; const indeterminate = allRowsSelectedStatus === 'some'; - const onChange = () => { - selectAllRows(); + const onChange = (e: React.ChangeEvent) => { + if (e.target.checked) { + setHasUserSelectedAllRows(true); + selectAllRows(); + } else { + setHasUserSelectedAllRows(false); + resetTableRowSelection(); + } }; return ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx b/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx index 2ccbae769852..d9f712ef82e4 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/context-menu/components/RecordTableContextMenu.tsx @@ -8,9 +8,9 @@ export const RecordTableContextMenu = ({ }: { recordTableId: string; }) => { - const { getSelectedRowIdsSelector } = useRecordTableStates(recordTableId); + const { selectedRowIdsSelector } = useRecordTableStates(recordTableId); - const selectedRowIds = useRecoilValue(getSelectedRowIdsSelector()); + const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); if (!selectedRowIds.length) { return null; diff --git a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts index 87ad5adb8019..80e2ad9a3f88 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableContext.ts @@ -4,7 +4,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; type RecordTableContextProps = { objectMetadataItem: ObjectMetadataItem; - recordTableRef: React.RefObject; }; export const RecordTableContext = createContext( diff --git a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts index 25035b383332..b57932023fa0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/contexts/RecordTableRowContext.ts @@ -5,6 +5,7 @@ type RecordTableRowContextProps = { recordId: string; rowIndex: number; isSelected: boolean; + isReadOnly: boolean; }; export const RecordTableRowContext = createContext( diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts index d8c21c844051..a9976aa05fac 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useCloseCurrentTableCellInEditMode.ts @@ -5,7 +5,7 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => { const { - getCurrentTableCellInEditModePositionState, + currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState, } = useRecordTableStates(recordTableId); @@ -14,7 +14,7 @@ export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => { return async () => { const currentTableCellInEditModePosition = getSnapshotValue( snapshot, - getCurrentTableCellInEditModePositionState(), + currentTableCellInEditModePositionState, ); set( @@ -23,9 +23,6 @@ export const useCloseCurrentTableCellInEditMode = (recordTableId?: string) => { ); }; }, - [ - getCurrentTableCellInEditModePositionState, - isTableCellInEditModeFamilyState, - ], + [currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useDisableSoftFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useDisableSoftFocus.ts index fed4211c976e..2e1704dfa5c5 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useDisableSoftFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useDisableSoftFocus.ts @@ -5,8 +5,8 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV export const useDisableSoftFocus = (recordTableId?: string) => { const { - getSoftFocusPositionState, - getIsSoftFocusActiveState, + softFocusPositionState, + isSoftFocusActiveState, isSoftFocusOnTableCellFamilyState, } = useRecordTableStates(recordTableId); @@ -15,17 +15,17 @@ export const useDisableSoftFocus = (recordTableId?: string) => { return () => { const currentPosition = getSnapshotValue( snapshot, - getSoftFocusPositionState(), + softFocusPositionState, ); - set(getIsSoftFocusActiveState(), false); + set(isSoftFocusActiveState, false); set(isSoftFocusOnTableCellFamilyState(currentPosition), false); }; }, [ - getIsSoftFocusActiveState, - getSoftFocusPositionState, + isSoftFocusActiveState, + softFocusPositionState, isSoftFocusOnTableCellFamilyState, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode.ts index 8a4b486258e2..ecc422eecd1e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode.ts @@ -5,7 +5,7 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV export const useGetIsSomeCellInEditModeState = (recordTableId?: string) => { const { - getCurrentTableCellInEditModePositionState, + currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState, } = useRecordTableStates(recordTableId); @@ -14,7 +14,7 @@ export const useGetIsSomeCellInEditModeState = (recordTableId?: string) => { () => { const currentTableCellInEditModePosition = getSnapshotValue( snapshot, - getCurrentTableCellInEditModePositionState(), + currentTableCellInEditModePositionState, ); const isSomeCellInEditModeState = isTableCellInEditModeFamilyState( @@ -23,9 +23,6 @@ export const useGetIsSomeCellInEditModeState = (recordTableId?: string) => { return isSomeCellInEditModeState; }, - [ - getCurrentTableCellInEditModePositionState, - isTableCellInEditModeFamilyState, - ], + [currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts index 5ce6a307f7f5..0ee3aa62d6a8 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useLeaveTableFocus.ts @@ -14,19 +14,19 @@ export const useLeaveTableFocus = (recordTableId?: string) => { const closeCurrentCellInEditMode = useCloseCurrentTableCellInEditMode(recordTableId); - const { getIsSoftFocusActiveState } = useRecordTableStates(recordTableId); + const { isSoftFocusActiveState } = useRecordTableStates(recordTableId); return useRecoilCallback( ({ snapshot }) => () => { const isSoftFocusActive = getSnapshotValue( snapshot, - getIsSoftFocusActiveState(), + isSoftFocusActiveState, ); const currentHotkeyScope = snapshot .getLoadable(currentHotkeyScopeState) - .valueOrThrow(); + .getValue(); if (!isSoftFocusActive) { return; @@ -39,6 +39,6 @@ export const useLeaveTableFocus = (recordTableId?: string) => { closeCurrentCellInEditMode(); disableSoftFocus(); }, - [closeCurrentCellInEditMode, disableSoftFocus, getIsSoftFocusActiveState], + [closeCurrentCellInEditMode, disableSoftFocus, isSoftFocusActiveState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts index 5bb32c4abe99..02e04a259d31 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useMoveEditModeToCellPosition.ts @@ -8,7 +8,7 @@ import { TableCellPosition } from '../../types/TableCellPosition'; export const useMoveEditModeToTableCellPosition = (recordTableId?: string) => { const { isTableCellInEditModeFamilyState, - getCurrentTableCellInEditModePositionState, + currentTableCellInEditModePositionState, } = useRecordTableStates(recordTableId); return useRecoilCallback( @@ -16,7 +16,7 @@ export const useMoveEditModeToTableCellPosition = (recordTableId?: string) => { return (newPosition: TableCellPosition) => { const currentTableCellInEditModePosition = getSnapshotValue( snapshot, - getCurrentTableCellInEditModePositionState(), + currentTableCellInEditModePositionState, ); set( @@ -24,14 +24,11 @@ export const useMoveEditModeToTableCellPosition = (recordTableId?: string) => { false, ); - set(getCurrentTableCellInEditModePositionState(), newPosition); + set(currentTableCellInEditModePositionState, newPosition); set(isTableCellInEditModeFamilyState(newPosition), true); }; }, - [ - getCurrentTableCellInEditModePositionState, - isTableCellInEditModeFamilyState, - ], + [currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts index 7238fec44150..1e178da5bd7a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useRecordTableStates.ts @@ -1,31 +1,35 @@ -import { isRowSelectedFamilyStateScopeMap } from '@/object-record/record-table/record-table-row/states/isRowSelectedFamilyStateScopeMap'; +import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState'; +import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext'; -import { availableTableColumnsStateScopeMap } from '@/object-record/record-table/states/availableTableColumnsStateScopeMap'; -import { currentTableCellInEditModePositionStateScopeMap } from '@/object-record/record-table/states/currentTableCellInEditModePositionStateScopeMap'; -import { isRecordTableInitialLoadingStateScopeMap } from '@/object-record/record-table/states/isRecordTableInitialLoadingStateScopeMap'; -import { isSoftFocusActiveStateScopeMap } from '@/object-record/record-table/states/isSoftFocusActiveStateScopeMap'; -import { isSoftFocusOnTableCellFamilyStateScopeMap } from '@/object-record/record-table/states/isSoftFocusOnTableCellFamilyStateScopeMap'; -import { isTableCellInEditModeFamilyStateScopeMap } from '@/object-record/record-table/states/isTableCellInEditModeFamilyStateScopeMap'; -import { numberOfTableRowsStateScopeMap } from '@/object-record/record-table/states/numberOfTableRowsStateScopeMap'; -import { onColumnsChangeStateScopeMap } from '@/object-record/record-table/states/onColumnsChangeStateScopeMap'; -import { onEntityCountChangeStateScopeMap } from '@/object-record/record-table/states/onEntityCountChangeStateScopeMap'; -import { resizeFieldOffsetStateScopeMap } from '@/object-record/record-table/states/resizeFieldOffsetStateScopeMap'; -import { allRowsSelectedStatusSelectorScopeMap } from '@/object-record/record-table/states/selectors/allRowsSelectedStatusSelectorScopeMap'; -import { hiddenTableColumnsSelectorScopeMap } from '@/object-record/record-table/states/selectors/hiddenTableColumnsSelectorScopeMap'; -import { numberOfTableColumnsSelectorScopeMap } from '@/object-record/record-table/states/selectors/numberOfTableColumnsSelectorScopeMap'; -import { selectedRowIdsSelectorScopeMap } from '@/object-record/record-table/states/selectors/selectedRowIdsSelectorScopeMap'; -import { visibleTableColumnsSelectorScopeMap } from '@/object-record/record-table/states/selectors/visibleTableColumnsSelectorScopeMap'; -import { softFocusPositionStateScopeMap } from '@/object-record/record-table/states/softFocusPositionStateScopeMap'; -import { tableColumnsStateScopeMap } from '@/object-record/record-table/states/tableColumnsStateScopeMap'; -import { tableFiltersStateScopeMap } from '@/object-record/record-table/states/tableFiltersStateScopeMap'; -import { tableLastRowVisibleStateScopeMap } from '@/object-record/record-table/states/tableLastRowVisibleStateScopeMap'; -import { tableRowIdsStateScopeMap } from '@/object-record/record-table/states/tableRowIdsStateScopeMap'; -import { tableSortsStateScopeMap } from '@/object-record/record-table/states/tableSortsStateScopeMap'; +import { availableTableColumnsComponentState } from '@/object-record/record-table/states/availableTableColumnsComponentState'; +import { currentTableCellInEditModePositionComponentState } from '@/object-record/record-table/states/currentTableCellInEditModePositionComponentState'; +import { isRecordTableInitialLoadingComponentState } from '@/object-record/record-table/states/isRecordTableInitialLoadingComponentState'; +import { isSoftFocusActiveComponentState } from '@/object-record/record-table/states/isSoftFocusActiveComponentState'; +import { isSoftFocusOnTableCellComponentFamilyState } from '@/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState'; +import { isTableCellInEditModeComponentFamilyState } from '@/object-record/record-table/states/isTableCellInEditModeComponentFamilyState'; +import { numberOfTableRowsComponentState } from '@/object-record/record-table/states/numberOfTableRowsComponentState'; +import { onColumnsChangeComponentState } from '@/object-record/record-table/states/onColumnsChangeComponentState'; +import { onEntityCountChangeComponentState } from '@/object-record/record-table/states/onEntityCountChangeComponentState'; +import { onToggleColumnFilterComponentState } from '@/object-record/record-table/states/onToggleColumnFilterComponentState'; +import { onToggleColumnSortComponentState } from '@/object-record/record-table/states/onToggleColumnSortComponentState'; +import { recordTablePendingRecordIdComponentState } from '@/object-record/record-table/states/recordTablePendingRecordIdComponentState'; +import { resizeFieldOffsetComponentState } from '@/object-record/record-table/states/resizeFieldOffsetComponentState'; +import { allRowsSelectedStatusComponentSelector } from '@/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector'; +import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector'; +import { numberOfTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/numberOfTableColumnsComponentSelector'; +import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; +import { softFocusPositionComponentState } from '@/object-record/record-table/states/softFocusPositionComponentState'; +import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; +import { tableFiltersComponentState } from '@/object-record/record-table/states/tableFiltersComponentState'; +import { tableLastRowVisibleComponentState } from '@/object-record/record-table/states/tableLastRowVisibleComponentState'; +import { tableRowIdsComponentState } from '@/object-record/record-table/states/tableRowIdsComponentState'; +import { tableSortsComponentState } from '@/object-record/record-table/states/tableSortsComponentState'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; -import { getFamilyState } from '@/ui/utilities/recoil-scope/utils/getFamilyState'; import { getScopeIdOrUndefinedFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdOrUndefinedFromComponentId'; -import { getSelectorReadOnly } from '@/ui/utilities/recoil-scope/utils/getSelectorReadOnly'; -import { getState } from '@/ui/utilities/recoil-scope/utils/getState'; +import { extractComponentFamilyState } from '@/ui/utilities/state/component-state/utils/extractComponentFamilyState'; +import { extractComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/extractComponentReadOnlySelector'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; export const useRecordTableStates = (recordTableId?: string) => { const scopeId = useAvailableScopeIdOrThrow( @@ -35,78 +39,102 @@ export const useRecordTableStates = (recordTableId?: string) => { return { scopeId, - getAvailableTableColumnsState: getState( - availableTableColumnsStateScopeMap, + availableTableColumnsState: extractComponentState( + availableTableColumnsComponentState, scopeId, ), - getTableFiltersState: getState(tableFiltersStateScopeMap, scopeId), - getTableSortsState: getState(tableSortsStateScopeMap, scopeId), - getTableColumnsState: getState(tableColumnsStateScopeMap, scopeId), - - getOnColumnsChangeState: getState(onColumnsChangeStateScopeMap, scopeId), - getOnEntityCountChangeState: getState( - onEntityCountChangeStateScopeMap, + tableFiltersState: extractComponentState( + tableFiltersComponentState, + scopeId, + ), + tableSortsState: extractComponentState(tableSortsComponentState, scopeId), + tableColumnsState: extractComponentState( + tableColumnsComponentState, + scopeId, + ), + onToggleColumnFilterState: extractComponentState( + onToggleColumnFilterComponentState, + scopeId, + ), + onToggleColumnSortState: extractComponentState( + onToggleColumnSortComponentState, + scopeId, + ), + onColumnsChangeState: extractComponentState( + onColumnsChangeComponentState, + scopeId, + ), + onEntityCountChangeState: extractComponentState( + onEntityCountChangeComponentState, + scopeId, + ), + tableLastRowVisibleState: extractComponentState( + tableLastRowVisibleComponentState, + scopeId, + ), + softFocusPositionState: extractComponentState( + softFocusPositionComponentState, scopeId, ), - getTableLastRowVisibleState: getState( - tableLastRowVisibleStateScopeMap, + numberOfTableRowsState: extractComponentState( + numberOfTableRowsComponentState, scopeId, ), - getSoftFocusPositionState: getState( - softFocusPositionStateScopeMap, + currentTableCellInEditModePositionState: extractComponentState( + currentTableCellInEditModePositionComponentState, scopeId, ), - getNumberOfTableRowsState: getState( - numberOfTableRowsStateScopeMap, + isTableCellInEditModeFamilyState: extractComponentFamilyState( + isTableCellInEditModeComponentFamilyState, scopeId, ), - getCurrentTableCellInEditModePositionState: getState( - currentTableCellInEditModePositionStateScopeMap, + isSoftFocusActiveState: extractComponentState( + isSoftFocusActiveComponentState, scopeId, ), - isTableCellInEditModeFamilyState: getFamilyState( - isTableCellInEditModeFamilyStateScopeMap, + tableRowIdsState: extractComponentState(tableRowIdsComponentState, scopeId), + isRecordTableInitialLoadingState: extractComponentState( + isRecordTableInitialLoadingComponentState, scopeId, ), - getIsSoftFocusActiveState: getState( - isSoftFocusActiveStateScopeMap, + resizeFieldOffsetState: extractComponentState( + resizeFieldOffsetComponentState, scopeId, ), - getTableRowIdsState: getState(tableRowIdsStateScopeMap, scopeId), - getIsRecordTableInitialLoadingState: getState( - isRecordTableInitialLoadingStateScopeMap, + isSoftFocusOnTableCellFamilyState: extractComponentFamilyState( + isSoftFocusOnTableCellComponentFamilyState, scopeId, ), - getResizeFieldOffsetState: getState( - resizeFieldOffsetStateScopeMap, + isRowSelectedFamilyState: extractComponentFamilyState( + isRowSelectedComponentFamilyState, scopeId, ), - isSoftFocusOnTableCellFamilyState: getFamilyState( - isSoftFocusOnTableCellFamilyStateScopeMap, + hasUserSelectedAllRowState: extractComponentState( + hasUserSelectedAllRowsComponentState, scopeId, ), - isRowSelectedFamilyState: getFamilyState( - isRowSelectedFamilyStateScopeMap, + allRowsSelectedStatusSelector: extractComponentReadOnlySelector( + allRowsSelectedStatusComponentSelector, scopeId, ), - getAllRowsSelectedStatusSelector: getSelectorReadOnly( - allRowsSelectedStatusSelectorScopeMap, + hiddenTableColumnsSelector: extractComponentReadOnlySelector( + hiddenTableColumnsComponentSelector, scopeId, ), - getHiddenTableColumnsSelector: getSelectorReadOnly( - hiddenTableColumnsSelectorScopeMap, + numberOfTableColumnsSelector: extractComponentReadOnlySelector( + numberOfTableColumnsComponentSelector, scopeId, ), - getNumberOfTableColumnsSelector: getSelectorReadOnly( - numberOfTableColumnsSelectorScopeMap, + selectedRowIdsSelector: extractComponentReadOnlySelector( + selectedRowIdsComponentSelector, scopeId, ), - getSelectedRowIdsSelector: getSelectorReadOnly( - selectedRowIdsSelectorScopeMap, + visibleTableColumnsSelector: extractComponentReadOnlySelector( + visibleTableColumnsComponentSelector, scopeId, ), - getVisibleTableColumnsSelector: getSelectorReadOnly( - visibleTableColumnsSelectorScopeMap, + pendingRecordIdState: extractComponentState( + recordTablePendingRecordIdComponentState, scopeId, ), }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts index c722d40bb509..7e86f581909c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useResetTableRowSelection.ts @@ -4,18 +4,18 @@ import { useRecordTableStates } from '@/object-record/record-table/hooks/interna import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; export const useResetTableRowSelection = (recordTableId?: string) => { - const { getTableRowIdsState, isRowSelectedFamilyState } = + const { tableRowIdsState, isRowSelectedFamilyState } = useRecordTableStates(recordTableId); return useRecoilCallback( ({ snapshot, set }) => () => { - const tableRowIds = getSnapshotValue(snapshot, getTableRowIdsState()); + const tableRowIds = getSnapshotValue(snapshot, tableRowIdsState); for (const rowId of tableRowIds) { set(isRowSelectedFamilyState(rowId), false); } }, - [getTableRowIdsState, isRowSelectedFamilyState], + [tableRowIdsState, isRowSelectedFamilyState], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSelectAllRows.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSelectAllRows.ts index 1a7e563c15d2..c76683e6ae87 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSelectAllRows.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSelectAllRows.ts @@ -5,8 +5,8 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV export const useSelectAllRows = (recordTableId?: string) => { const { - getAllRowsSelectedStatusSelector, - getTableRowIdsState, + allRowsSelectedStatusSelector, + tableRowIdsState, isRowSelectedFamilyState, } = useRecordTableStates(recordTableId); @@ -15,10 +15,10 @@ export const useSelectAllRows = (recordTableId?: string) => { () => { const allRowsSelectedStatus = getSnapshotValue( snapshot, - getAllRowsSelectedStatusSelector(), + allRowsSelectedStatusSelector(), ); - const tableRowIds = getSnapshotValue(snapshot, getTableRowIdsState()); + const tableRowIds = getSnapshotValue(snapshot, tableRowIdsState); if ( allRowsSelectedStatus === 'none' || @@ -33,11 +33,7 @@ export const useSelectAllRows = (recordTableId?: string) => { } } }, - [ - getAllRowsSelectedStatusSelector, - getTableRowIdsState, - isRowSelectedFamilyState, - ], + [allRowsSelectedStatusSelector, tableRowIdsState, isRowSelectedFamilyState], ); return { diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts new file mode 100644 index 000000000000..4544c4b71f95 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetAllRowSelectedState.ts @@ -0,0 +1,16 @@ +import { useRecoilCallback } from 'recoil'; + +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; + +export const useSetHasUserSelectedAllRows = (recordTableId?: string) => { + const { hasUserSelectedAllRowState: hasUserSelectedAllRowFamilyState } = + useRecordTableStates(recordTableId); + + return useRecoilCallback( + ({ set }) => + (selected: boolean) => { + set(hasUserSelectedAllRowFamilyState, selected); + }, + [hasUserSelectedAllRowFamilyState], + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts index f6fc8e1df828..781d348b45eb 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRecordTableData.ts @@ -14,8 +14,12 @@ export const useSetRecordTableData = ({ recordTableId, onEntityCountChange, }: useSetRecordTableDataProps) => { - const { getTableRowIdsState, getNumberOfTableRowsState } = - useRecordTableStates(recordTableId); + const { + tableRowIdsState, + numberOfTableRowsState, + isRowSelectedFamilyState, + hasUserSelectedAllRowState, + } = useRecordTableStates(recordTableId); return useRecoilCallback( ({ set, snapshot }) => @@ -24,23 +28,40 @@ export const useSetRecordTableData = ({ // TODO: refactor with scoped state later const currentEntity = snapshot .getLoadable(recordStoreFamilyState(entity.id)) - .valueOrThrow(); + .getValue(); if (JSON.stringify(currentEntity) !== JSON.stringify(entity)) { set(recordStoreFamilyState(entity.id), entity); } } - const currentRowIds = getSnapshotValue(snapshot, getTableRowIdsState()); + const currentRowIds = getSnapshotValue(snapshot, tableRowIdsState); + + const hasUserSelectedAllRows = getSnapshotValue( + snapshot, + hasUserSelectedAllRowState, + ); const entityIds = newEntityArray.map((entity) => entity.id); if (!isDeeplyEqual(currentRowIds, entityIds)) { - set(getTableRowIdsState(), entityIds); + set(tableRowIdsState, entityIds); + } + + if (hasUserSelectedAllRows) { + for (const rowId of entityIds) { + set(isRowSelectedFamilyState(rowId), true); + } } - set(getNumberOfTableRowsState(), totalCount); + set(numberOfTableRowsState, totalCount); onEntityCountChange(totalCount); }, - [getNumberOfTableRowsState, getTableRowIdsState, onEntityCountChange], + [ + numberOfTableRowsState, + tableRowIdsState, + onEntityCountChange, + isRowSelectedFamilyState, + hasUserSelectedAllRowState, + ], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRowSelectedState.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRowSelectedState.ts index 2cce04a60013..923121c843a0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRowSelectedState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetRowSelectedState.ts @@ -5,7 +5,11 @@ import { useRecordTableStates } from '@/object-record/record-table/hooks/interna export const useSetRowSelectedState = (recordTableId?: string) => { const { isRowSelectedFamilyState } = useRecordTableStates(recordTableId); - return useRecoilCallback(({ set }) => (rowId: string, selected: boolean) => { - set(isRowSelectedFamilyState(rowId), selected); - }); + return useRecoilCallback( + ({ set }) => + (rowId: string, selected: boolean) => { + set(isRowSelectedFamilyState(rowId), selected); + }, + [isRowSelectedFamilyState], + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts index 5c0408c625e3..edf8f7a904e0 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/internal/useSetSoftFocusPosition.ts @@ -7,8 +7,8 @@ import { TableCellPosition } from '../../types/TableCellPosition'; export const useSetSoftFocusPosition = (recordTableId?: string) => { const { - getSoftFocusPositionState, - getIsSoftFocusActiveState, + softFocusPositionState, + isSoftFocusActiveState, isSoftFocusOnTableCellFamilyState, } = useRecordTableStates(recordTableId); @@ -17,21 +17,21 @@ export const useSetSoftFocusPosition = (recordTableId?: string) => { return (newPosition: TableCellPosition) => { const currentPosition = getSnapshotValue( snapshot, - getSoftFocusPositionState(), + softFocusPositionState, ); - set(getIsSoftFocusActiveState(), true); + set(isSoftFocusActiveState, true); set(isSoftFocusOnTableCellFamilyState(currentPosition), false); - set(getSoftFocusPositionState(), newPosition); + set(softFocusPositionState, newPosition); set(isSoftFocusOnTableCellFamilyState(newPosition), true); }; }, [ - getSoftFocusPositionState, - getIsSoftFocusActiveState, + softFocusPositionState, + isSoftFocusActiveState, isSoftFocusOnTableCellFamilyState, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts index 2941893c6b43..4ff7c65527f4 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTable.ts @@ -4,6 +4,7 @@ import { Key } from 'ts-key-enum'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { useGetIsSomeCellInEditModeState } from '@/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode'; import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { useSetHasUserSelectedAllRows } from '@/object-record/record-table/hooks/internal/useSetAllRowSelectedState'; import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus'; import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -32,51 +33,55 @@ export const useRecordTable = (props?: useRecordTableProps) => { const { scopeId, - getAvailableTableColumnsState, - getTableFiltersState, - getTableSortsState, - getTableColumnsState, - getOnEntityCountChangeState, - getOnColumnsChangeState, - getIsRecordTableInitialLoadingState, - getTableLastRowVisibleState, - getSelectedRowIdsSelector, + availableTableColumnsState, + tableFiltersState, + tableSortsState, + tableColumnsState, + onEntityCountChangeState, + onColumnsChangeState, + isRecordTableInitialLoadingState, + tableLastRowVisibleState, + selectedRowIdsSelector, + onToggleColumnFilterState, + onToggleColumnSortState, + pendingRecordIdState, } = useRecordTableStates(recordTableId); const setAvailableTableColumns = useRecoilCallback( ({ snapshot, set }) => (columns: ColumnDefinition[]) => { - const availableTableColumnsState = getSnapshotValue( + const availableTableColumns = getSnapshotValue( snapshot, - getAvailableTableColumnsState(), + availableTableColumnsState, ); - if (isDeeplyEqual(availableTableColumnsState, columns)) { + if (isDeeplyEqual(availableTableColumns, columns)) { return; } - set(getAvailableTableColumnsState(), columns); + set(availableTableColumnsState, columns); }, - [getAvailableTableColumnsState], + [availableTableColumnsState], ); - const setOnEntityCountChange = useSetRecoilState( - getOnEntityCountChangeState(), - ); + const setOnEntityCountChange = useSetRecoilState(onEntityCountChangeState); + + const setTableFilters = useSetRecoilState(tableFiltersState); - const setTableFilters = useSetRecoilState(getTableFiltersState()); + const setTableSorts = useSetRecoilState(tableSortsState); - const setTableSorts = useSetRecoilState(getTableSortsState()); + const setTableColumns = useSetRecoilState(tableColumnsState); - const setTableColumns = useSetRecoilState(getTableColumnsState()); + const setOnColumnsChange = useSetRecoilState(onColumnsChangeState); - const setOnColumnsChange = useSetRecoilState(getOnColumnsChangeState()); + const setOnToggleColumnFilter = useSetRecoilState(onToggleColumnFilterState); + const setOnToggleColumnSort = useSetRecoilState(onToggleColumnSortState); const setIsRecordTableInitialLoading = useSetRecoilState( - getIsRecordTableInitialLoadingState(), + isRecordTableInitialLoadingState, ); const setRecordTableLastRowVisible = useSetRecoilState( - getTableLastRowVisibleState(), + tableLastRowVisibleState, ); const onColumnsChange = useRecoilCallback( @@ -84,12 +89,12 @@ export const useRecordTable = (props?: useRecordTableProps) => { (columns: ColumnDefinition[]) => { const onColumnsChange = getSnapshotValue( snapshot, - getOnColumnsChangeState(), + onColumnsChangeState, ); onColumnsChange?.(columns); }, - [getOnColumnsChangeState], + [onColumnsChangeState], ); const onEntityCountChange = useRecoilCallback( @@ -97,12 +102,12 @@ export const useRecordTable = (props?: useRecordTableProps) => { (count: number) => { const onEntityCountChange = getSnapshotValue( snapshot, - getOnEntityCountChangeState(), + onEntityCountChangeState, ); onEntityCountChange?.(count); }, - [getOnEntityCountChangeState], + [onEntityCountChangeState], ); const setRecordTableData = useSetRecordTableData({ @@ -112,11 +117,13 @@ export const useRecordTable = (props?: useRecordTableProps) => { const leaveTableFocus = useLeaveTableFocus(recordTableId); - const setRowSelectedState = useSetRowSelectedState(recordTableId); + const setRowSelected = useSetRowSelectedState(recordTableId); + + const setHasUserSelectedAllRows = useSetHasUserSelectedAllRows(recordTableId); const resetTableRowSelection = useResetTableRowSelection(recordTableId); - const upsertRecordTableItem = useUpsertRecordFromState(); + const upsertRecordTableItem = useUpsertRecordFromState; const setSoftFocusPosition = useSetSoftFocusPosition(recordTableId); @@ -188,6 +195,8 @@ export const useRecordTable = (props?: useRecordTableProps) => { const isSomeCellInEditModeState = useGetIsSomeCellInEditModeState(recordTableId); + const setPendingRecordId = useSetRecoilState(pendingRecordIdState); + return { scopeId, onColumnsChange, @@ -198,7 +207,7 @@ export const useRecordTable = (props?: useRecordTableProps) => { setRecordTableData, setTableColumns, leaveTableFocus, - setRowSelectedState, + setRowSelected, resetTableRowSelection, upsertRecordTableItem, moveDown, @@ -212,6 +221,10 @@ export const useRecordTable = (props?: useRecordTableProps) => { setRecordTableLastRowVisible, setSoftFocusPosition, isSomeCellInEditModeState, - getSelectedRowIdsSelector, + selectedRowIdsSelector, + setHasUserSelectedAllRows, + setOnToggleColumnFilter, + setOnToggleColumnSort, + setPendingRecordId, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts index 7c5de53e7045..2291659af820 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useRecordTableMoveFocus.ts @@ -8,10 +8,10 @@ import { useSetSoftFocusPosition } from './internal/useSetSoftFocusPosition'; export const useRecordTableMoveFocus = (recordTableId?: string) => { const { scopeId, - getSoftFocusPositionState, - getNumberOfTableRowsState, - getNumberOfTableColumnsSelector, - getSelectedRowIdsSelector, + softFocusPositionState, + numberOfTableRowsState, + numberOfTableColumnsSelector, + selectedRowIdsSelector, } = useRecordTableStates(recordTableId); const setSoftFocusPosition = useSetSoftFocusPosition(recordTableId); @@ -21,7 +21,7 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { () => { const softFocusPosition = getSnapshotValue( snapshot, - getSoftFocusPositionState(), + softFocusPositionState, ); let newRowNumber = softFocusPosition.row - 1; @@ -35,7 +35,7 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { row: newRowNumber, }); }, - [getSoftFocusPositionState, setSoftFocusPosition], + [softFocusPositionState, setSoftFocusPosition], ); const moveDown = useRecoilCallback( @@ -43,12 +43,12 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { () => { const softFocusPosition = getSnapshotValue( snapshot, - getSoftFocusPositionState(), + softFocusPositionState, ); const numberOfTableRows = getSnapshotValue( snapshot, - getNumberOfTableRowsState(), + numberOfTableRowsState, ); let newRowNumber = softFocusPosition.row + 1; @@ -62,11 +62,7 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { row: newRowNumber, }); }, - [ - getNumberOfTableRowsState, - setSoftFocusPosition, - getSoftFocusPositionState, - ], + [numberOfTableRowsState, setSoftFocusPosition, softFocusPositionState], ); const moveRight = useRecoilCallback( @@ -74,17 +70,17 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { () => { const softFocusPosition = getSnapshotValue( snapshot, - getSoftFocusPositionState(), + softFocusPositionState, ); const numberOfTableColumns = getSnapshotValue( snapshot, - getNumberOfTableColumnsSelector(), + numberOfTableColumnsSelector(), ); const numberOfTableRows = getSnapshotValue( snapshot, - getNumberOfTableRowsState(), + numberOfTableRowsState, ); const currentColumnNumber = softFocusPosition.column; const currentRowNumber = softFocusPosition.row; @@ -117,9 +113,9 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { } }, [ - getSoftFocusPositionState, - getNumberOfTableColumnsSelector, - getNumberOfTableRowsState, + softFocusPositionState, + numberOfTableColumnsSelector, + numberOfTableRowsState, setSoftFocusPosition, ], ); @@ -129,12 +125,12 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { () => { const softFocusPosition = getSnapshotValue( snapshot, - getSoftFocusPositionState(), + softFocusPositionState, ); const numberOfTableColumns = getSnapshotValue( snapshot, - getNumberOfTableColumnsSelector(), + numberOfTableColumnsSelector(), ); const currentColumnNumber = softFocusPosition.column; @@ -165,8 +161,8 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { } }, [ - getNumberOfTableColumnsSelector, - getSoftFocusPositionState, + numberOfTableColumnsSelector, + softFocusPositionState, setSoftFocusPosition, ], ); @@ -178,6 +174,6 @@ export const useRecordTableMoveFocus = (recordTableId?: string) => { moveRight, moveUp, setSoftFocusPosition, - getSelectedRowIdsSelector, + selectedRowIdsSelector, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useTableColumns.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useTableColumns.ts index 1cc9fa344416..534db9462588 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useTableColumns.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useTableColumns.ts @@ -18,15 +18,15 @@ export const useTableColumns = (props?: useRecordTableProps) => { }); const { - getAvailableTableColumnsState, - getTableColumnsState, - getVisibleTableColumnsSelector, + availableTableColumnsState, + tableColumnsState, + visibleTableColumnsSelector, } = useRecordTableStates(props?.recordTableId); - const availableTableColumns = useRecoilValue(getAvailableTableColumnsState()); + const availableTableColumns = useRecoilValue(availableTableColumnsState); - const tableColumns = useRecoilValue(getTableColumnsState()); - const visibleTableColumns = useRecoilValue(getVisibleTableColumnsSelector()); + const tableColumns = useRecoilValue(tableColumnsState); + const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); const { handleColumnMove } = useMoveViewColumns(); @@ -47,6 +47,11 @@ export const useTableColumns = (props?: useRecordTableProps) => { (tableColumns) => tableColumns.fieldMetadataId === viewField.fieldMetadataId, ); + const lastTableColumnPosition = [...tableColumns] + .sort((a, b) => b.position - a.position) + .map((column) => column.position); + + const lastPosition = lastTableColumnPosition[0] ?? 0; if (isNewColumn) { const newColumn = availableTableColumns.find( @@ -57,7 +62,7 @@ export const useTableColumns = (props?: useRecordTableProps) => { const nextColumns = [ ...tableColumns, - { ...newColumn, isVisible: true }, + { ...newColumn, isVisible: true, position: lastPosition + 1 }, ]; await handleColumnsChange(nextColumns); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx index 1c997b125eeb..945fd2e24d96 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCell.tsx @@ -4,9 +4,11 @@ import { FieldDisplay } from '@/object-record/record-field/components/FieldDispl import { FieldInput } from '@/object-record/record-field/components/FieldInput'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { FieldInputEvent } from '@/object-record/record-field/types/FieldInputEvent'; +import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useRecordTableMoveFocus } from '@/object-record/record-table/hooks/useRecordTableMoveFocus'; import { RecordTableCellContainer } from '@/object-record/record-table/record-table-cell/components/RecordTableCellContainer'; import { useCloseRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCell'; +import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; export const RecordTableCell = ({ @@ -15,19 +17,22 @@ export const RecordTableCell = ({ customHotkeyScope: HotkeyScope; }) => { const { closeTableCell } = useCloseRecordTableCell(); - const { entityId, fieldDefinition } = useContext(FieldContext); + const { upsertRecord } = useUpsertRecord(); const { moveLeft, moveRight, moveDown } = useRecordTableMoveFocus(); + const { entityId, fieldDefinition } = useContext(FieldContext); + const { isReadOnly } = useContext(RecordTableRowContext); + const handleEnter: FieldInputEvent = (persistField) => { - persistField(); + upsertRecord(persistField); closeTableCell(); moveDown(); }; const handleSubmit: FieldInputEvent = (persistField) => { - persistField(); + upsertRecord(persistField); closeTableCell(); }; @@ -37,26 +42,26 @@ export const RecordTableCell = ({ }; const handleClickOutside: FieldInputEvent = (persistField) => { - persistField(); + upsertRecord(persistField); closeTableCell(); }; const handleEscape: FieldInputEvent = (persistField) => { - persistField(); + upsertRecord(persistField); closeTableCell(); }; const handleTab: FieldInputEvent = (persistField) => { - persistField(); + upsertRecord(persistField); closeTableCell(); moveRight(); }; const handleShiftTab: FieldInputEvent = (persistField) => { - persistField(); + upsertRecord(persistField); closeTableCell(); moveLeft(); @@ -75,6 +80,7 @@ export const RecordTableCell = ({ onShiftTab={handleShiftTab} onSubmit={handleSubmit} onTab={handleTab} + isReadOnly={isReadOnly} /> } nonEditModeContent={} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx index 344988c941af..6b7f06bab804 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellContainer.tsx @@ -1,16 +1,21 @@ import { ReactElement, useContext, useState } from 'react'; import styled from '@emotion/styled'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useRecoilCallback, useSetRecoilState } from 'recoil'; +import { IconArrowUpRight } from 'twenty-ui'; import { useGetButtonIcon } from '@/object-record/record-field/hooks/useGetButtonIcon'; import { useIsFieldEmpty } from '@/object-record/record-field/hooks/useIsFieldEmpty'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; import { RecordTableCellContext } from '@/object-record/record-table/contexts/RecordTableCellContext'; +import { RecordTableRowContext } from '@/object-record/record-table/contexts/RecordTableRowContext'; import { useGetIsSomeCellInEditModeState } from '@/object-record/record-table/hooks/internal/useGetIsSomeCellInEditMode'; import { useOpenRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell'; +import { useSetCurrentRowSelected } from '@/object-record/record-table/record-table-row/hooks/useSetCurrentRowSelected'; import { isSoftFocusUsingMouseState } from '@/object-record/record-table/states/isSoftFocusUsingMouseState'; -import { IconArrowUpRight } from '@/ui/display/icon'; +import { contextMenuIsOpenState } from '@/ui/navigation/context-menu/states/contextMenuIsOpenState'; +import { contextMenuPositionState } from '@/ui/navigation/context-menu/states/contextMenuPositionState'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; @@ -24,6 +29,12 @@ import { RecordTableCellDisplayMode } from './RecordTableCellDisplayMode'; import { RecordTableCellEditMode } from './RecordTableCellEditMode'; import { RecordTableCellSoftFocusMode } from './RecordTableCellSoftFocusMode'; +const StyledTd = styled.td<{ isSelected: boolean; isInEditMode: boolean }>` + background: ${({ isSelected, theme }) => + isSelected ? theme.accent.quaternary : theme.background.primary}; + z-index: ${({ isInEditMode }) => (isInEditMode ? '4 !important' : '3')}; +`; + const StyledCellBaseContainer = styled.div` align-items: center; box-sizing: border-box; @@ -61,7 +72,6 @@ export const RecordTableCellContainer = ({ const { isCurrentTableCellInEditMode } = useCurrentTableCellEditMode(); const isSomeCellInEditModeState = useGetIsSomeCellInEditModeState(); - const isSomeCellInEditMode = useRecoilValue(isSomeCellInEditModeState()); const setIsSoftFocusUsingMouseState = useSetRecoilState( isSoftFocusUsingMouseState, @@ -80,14 +90,44 @@ export const RecordTableCellContainer = ({ openTableCell(); }; - const handleContainerMouseEnter = () => { - if (!isHovered && !isSomeCellInEditMode) { - setIsHovered(true); - moveSoftFocusToCurrentCellOnHover(); - setIsSoftFocusUsingMouseState(true); - } + const { isSelected, isReadOnly } = useContext(RecordTableRowContext); + + const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); + const setContextMenuOpenState = useSetRecoilState(contextMenuIsOpenState); + + const { setCurrentRowSelected } = useSetCurrentRowSelected(); + + const handleContextMenu = (event: React.MouseEvent) => { + event.preventDefault(); + setCurrentRowSelected(true); + setContextMenuPosition({ + x: event.clientX, + y: event.clientY, + }); + setContextMenuOpenState(true); }; + const handleContainerMouseEnter = useRecoilCallback( + ({ snapshot }) => + () => { + const isSomeCellInEditMode = getSnapshotValue( + snapshot, + isSomeCellInEditModeState(), + ); + if (!isHovered && !isSomeCellInEditMode) { + setIsHovered(true); + moveSoftFocusToCurrentCellOnHover(); + setIsSoftFocusUsingMouseState(true); + } + }, + [ + isHovered, + isSomeCellInEditModeState, + moveSoftFocusToCurrentCellOnHover, + setIsSoftFocusUsingMouseState, + ], + ); + const handleContainerMouseLeave = () => { setIsHovered(false); }; @@ -106,49 +146,56 @@ export const RecordTableCellContainer = ({ hasSoftFocus && !isCurrentTableCellInEditMode && !editModeContentOnly && - (!isFirstColumn || !isEmpty); + (!isFirstColumn || !isEmpty) && + !isReadOnly; return ( - handleContextMenu(event)} + isInEditMode={isCurrentTableCellInEditMode} > - - {isCurrentTableCellInEditMode ? ( - - {editModeContent} - - ) : hasSoftFocus ? ( - <> - {showButton && ( - - )} - - {editModeContentOnly ? editModeContent : nonEditModeContent} - - - ) : ( - <> - {showButton && ( - - )} - - {editModeContentOnly ? editModeContent : nonEditModeContent} - - - )} - - + + {isCurrentTableCellInEditMode ? ( + + {editModeContent} + + ) : hasSoftFocus ? ( + <> + {showButton && ( + + )} + + {editModeContentOnly ? editModeContent : nonEditModeContent} + + + ) : ( + <> + {showButton && ( + + )} + + {editModeContentOnly ? editModeContent : nonEditModeContent} + + + )} + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx index 7c6947aa5cf1..7f10d98ba72a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellSoftFocusMode.tsx @@ -2,6 +2,8 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; import { useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; +import { useClearField } from '@/object-record/record-field/hooks/useClearField'; +import { useIsFieldClearable } from '@/object-record/record-field/hooks/useIsFieldClearable'; import { useIsFieldInputOnly } from '@/object-record/record-field/hooks/useIsFieldInputOnly'; import { useToggleEditOnlyInput } from '@/object-record/record-field/hooks/useToggleEditOnlyInput'; import { useOpenRecordTableCell } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell'; @@ -21,10 +23,14 @@ export const RecordTableCellSoftFocusMode = ({ const { openTableCell } = useOpenRecordTableCell(); const isFieldInputOnly = useIsFieldInputOnly(); + + const isFieldClearable = useIsFieldClearable(); + const toggleEditOnlyInput = useToggleEditOnlyInput(); const scrollRef = useRef(null); const isSoftFocusUsingMouse = useRecoilValue(isSoftFocusUsingMouseState); + const clearField = useClearField(); useEffect(() => { if (!isSoftFocusUsingMouse) { @@ -35,12 +41,12 @@ export const RecordTableCellSoftFocusMode = ({ useScopedHotkeys( [Key.Backspace, Key.Delete], () => { - if (!isFieldInputOnly) { - openTableCell(); + if (!isFieldInputOnly && isFieldClearable) { + clearField(); } }, TableHotkeyScope.TableSoftFocus, - [openTableCell], + [clearField, isFieldClearable, isFieldInputOnly], { enabled: !isFieldInputOnly, }, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts index af1402fbabb4..1f8ddd25b16b 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__mocks__/cell.ts @@ -1,5 +1,6 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; export const recordTableRow = { rowIndex: 2, @@ -19,7 +20,7 @@ export const recordTableCell: { fieldMetadataId: 'fieldMetadataId', label: 'label', iconName: 'iconName', - type: 'TEXT', + type: FieldMetadataType.Text, metadata: { placeHolder: 'placeHolder', fieldName: 'fieldName', diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useCloseRecordTableCell.test.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useCloseRecordTableCell.test.tsx index c608e921e635..7e6dfb6f501c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useCloseRecordTableCell.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useCloseRecordTableCell.test.tsx @@ -55,11 +55,11 @@ describe('useCloseRecordTableCell', () => { const { result } = renderHook( () => { const { - getCurrentTableCellInEditModePositionState, + currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState, } = useRecordTableStates(); const currentTableCellInEditModePosition = useRecoilValue( - getCurrentTableCellInEditModePositionState(), + currentTableCellInEditModePositionState, ); const isTableCellInEditMode = useRecoilValue( isTableCellInEditModeFamilyState(currentTableCellInEditModePosition), diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useMoveSoftFocusToCurrentCellOnHover.test.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useMoveSoftFocusToCurrentCellOnHover.test.tsx index f8337c3ba891..b7946416501a 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useMoveSoftFocusToCurrentCellOnHover.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useMoveSoftFocusToCurrentCellOnHover.test.tsx @@ -13,19 +13,19 @@ import { TableCellPosition } from '@/object-record/record-table/types/TableCellP import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; const mockSoftFocusPositionState = { - key: 'softFocusPositionStateScopeMap__{"scopeId":"scopeId"}', + key: 'softFocusPositionComponentState__{"scopeId":"scopeId"}', }; const mockSoftFocusActiveState = { - key: 'isSoftFocusActiveStateScopeMap__{"scopeId":"scopeId"}', + key: 'isSoftFocusActiveComponentState__{"scopeId":"scopeId"}', }; const mockIsSoftFocusOnTableCellFamilyState = { - key: 'isSoftFocusOnTableCellFamilyStateScopeMap__{"familyKey":{"column":1,"row":0},"scopeId":"scopeId"}', + key: 'isSoftFocusOnTableCellFamilyComponentState__{"familyKey":{"column":1,"row":0},"scopeId":"scopeId"}', }; const mockCurrentTableCellInEditModePositionState = { - key: 'currentTableCellInEditModePositionStateScopeMap__{"scopeId":"scopeId"}', + key: 'currentTableCellInEditModePositionComponentState__{"scopeId":"scopeId"}', }; const mockIsTableCellInEditModeFamilyState = { - key: 'isTableCellInEditModeFamilyStateScopeMap__{"familyKey":{"column":1,"row":0},"scopeId":"scopeId"}', + key: 'isTableCellInEditModeFamilyComponentState__{"familyKey":{"column":1,"row":0},"scopeId":"scopeId"}', }; const mockCurrentHotKeyScopeState = { key: 'currentHotkeyScopeState', @@ -65,11 +65,11 @@ jest.mock( '@/object-record/record-table/hooks/internal/useRecordTableStates', () => ({ useRecordTableStates: () => ({ - getSoftFocusPositionState: () => mockSoftFocusPositionState, - getIsSoftFocusActiveState: () => mockSoftFocusActiveState, + softFocusPositionState: mockSoftFocusPositionState, + isSoftFocusActiveState: mockSoftFocusActiveState, isSoftFocusOnTableCellFamilyState: () => mockIsSoftFocusOnTableCellFamilyState, - getCurrentTableCellInEditModePositionState: () => + currentTableCellInEditModePositionState: mockCurrentTableCellInEditModePositionState, isTableCellInEditModeFamilyState: () => mockIsTableCellInEditModeFamilyState, diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSelectedTableCellEditMode.test.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSelectedTableCellEditMode.test.tsx index 9cbf45efe272..1e7ec2088d27 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSelectedTableCellEditMode.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useSelectedTableCellEditMode.test.tsx @@ -48,19 +48,14 @@ describe('useSelectedTableCellEditMode', () => { expect(mockCallbackInterface.set).toHaveBeenCalledWith( { - key: 'isTableCellInEditModeFamilyStateScopeMap__{"familyKey":{"column":0,"row":0},"scopeId":"yourScopeId-scope"}', + key: 'isTableCellInEditModeComponentFamilyState__{"familyKey":{"column":0,"row":0},"scopeId":"yourScopeId-scope"}', }, false, ); + expect(mockCallbackInterface.set).toHaveBeenCalledWith( { - key: 'currentTableCellInEditModePositionStateScopeMap__{"scopeId":"yourScopeId-scope"}', - }, - { column: 5, row: 1 }, - ); - expect(mockCallbackInterface.set).toHaveBeenCalledWith( - { - key: 'isTableCellInEditModeFamilyStateScopeMap__{"familyKey":{"column":5,"row":1},"scopeId":"yourScopeId-scope"}', + key: 'isTableCellInEditModeComponentFamilyState__{"familyKey":{"column":5,"row":1},"scopeId":"yourScopeId-scope"}', }, true, ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useUpsertRecord.test.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useUpsertRecord.test.tsx new file mode 100644 index 000000000000..291c7407df60 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/__tests__/useUpsertRecord.test.tsx @@ -0,0 +1,152 @@ +import { ReactNode } from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { RecoilRoot } from 'recoil'; +import { createState } from 'twenty-ui'; + +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { textfieldDefinition } from '@/object-record/record-field/__mocks__/fieldDefinitions'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { useUpsertRecord } from '@/object-record/record-table/record-table-cell/hooks/useUpsertRecord'; +import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; + +const pendingRecordId = 'a7286b9a-c039-4a89-9567-2dfa7953cda9'; +const draftValue = 'updated Name'; + +jest.mock('@/object-record/hooks/useCreateOneRecord', () => ({ + __esModule: true, + useCreateOneRecord: jest.fn(), +})); + +const draftValueState = createState({ + key: 'draftValueState', + defaultValue: null, +}); +jest.mock( + '@/object-record/record-field/hooks/internal/useRecordFieldInputStates', + () => ({ + __esModule: true, + useRecordFieldInputStates: jest.fn(() => ({ + getDraftValueSelector: () => draftValueState, + })), + }), +); + +const pendingRecordIdState = createState({ + key: 'pendingRecordIdState', + defaultValue: null, +}); +jest.mock( + '@/object-record/record-table/hooks/internal/useRecordTableStates', + () => ({ + __esModule: true, + useRecordTableStates: jest.fn(() => ({ + pendingRecordIdState: pendingRecordIdState, + })), + }), +); + +const createOneRecordMock = jest.fn(); +const updateOneRecordMock = jest.fn(); +(useCreateOneRecord as jest.Mock).mockReturnValue({ + createOneRecord: createOneRecordMock, +}); + +const Wrapper = ({ + children, + pendingRecordIdMockedValue, + draftValueMockedValue, +}: { + children: ReactNode; + pendingRecordIdMockedValue: string | null; + draftValueMockedValue: string | null; +}) => ( + { + snapshot.set(pendingRecordIdState, pendingRecordIdMockedValue); + snapshot.set(draftValueState, draftValueMockedValue); + }} + > + + {children} + + +); + +describe('useUpsertRecord', () => { + beforeEach(async () => { + createOneRecordMock.mockClear(); + updateOneRecordMock.mockClear(); + }); + + it('calls update record if there is no pending record', async () => { + const { result } = renderHook(() => useUpsertRecord(), { + wrapper: ({ children }) => + Wrapper({ + pendingRecordIdMockedValue: null, + draftValueMockedValue: null, + children, + }), + }); + + await act(async () => { + await result.current.upsertRecord(updateOneRecordMock); + }); + + expect(createOneRecordMock).not.toHaveBeenCalled(); + expect(updateOneRecordMock).toHaveBeenCalled(); + }); + + it('calls update record if pending record is empty', async () => { + const { result } = renderHook(() => useUpsertRecord(), { + wrapper: ({ children }) => + Wrapper({ + pendingRecordIdMockedValue: null, + draftValueMockedValue: draftValue, + children, + }), + }); + + await act(async () => { + await result.current.upsertRecord(updateOneRecordMock); + }); + + expect(createOneRecordMock).not.toHaveBeenCalled(); + expect(updateOneRecordMock).toHaveBeenCalled(); + }); + + it('calls create record if pending record is not empty', async () => { + const { result } = renderHook(() => useUpsertRecord(), { + wrapper: ({ children }) => + Wrapper({ + pendingRecordIdMockedValue: pendingRecordId, + draftValueMockedValue: draftValue, + children, + }), + }); + + await act(async () => { + await result.current.upsertRecord(updateOneRecordMock); + }); + + expect(createOneRecordMock).toHaveBeenCalledWith({ + id: pendingRecordId, + name: draftValue, + position: 'first', + }); + expect(updateOneRecordMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCell.ts index 9ba618747f1a..02936662619e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useCloseRecordTableCell.ts @@ -1,3 +1,6 @@ +import { useResetRecoilState } from 'recoil'; + +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; @@ -7,13 +10,17 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope'; export const useCloseRecordTableCell = () => { const setHotkeyScope = useSetHotkeyScope(); const { setDragSelectionStartEnabled } = useDragSelect(); + const { pendingRecordIdState } = useRecordTableStates(); const closeCurrentTableCellInEditMode = useCloseCurrentTableCellInEditMode(); + const resetRecordTablePendingRecordId = + useResetRecoilState(pendingRecordIdState); const closeTableCell = async () => { setDragSelectionStartEnabled(true); closeCurrentTableCellInEditMode(); setHotkeyScope(TableHotkeyScope.TableSoftFocus); + resetRecordTablePendingRecordId(); }; return { diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCurrentCellOnHover.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCurrentCellOnHover.ts index 4bb8b9f8747b..3d53b8d9b0c9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCurrentCellOnHover.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useMoveSoftFocusToCurrentCellOnHover.ts @@ -12,7 +12,7 @@ export const useMoveSoftFocusToCurrentCellOnHover = () => { const setSoftFocusOnCurrentTableCell = useSetSoftFocusOnCurrentTableCell(); const { - getCurrentTableCellInEditModePositionState, + currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState, } = useRecordTableStates(); @@ -21,7 +21,7 @@ export const useMoveSoftFocusToCurrentCellOnHover = () => { () => { const currentTableCellInEditModePosition = getSnapshotValue( snapshot, - getCurrentTableCellInEditModePositionState(), + currentTableCellInEditModePositionState, ); const isSomeCellInEditMode = snapshot @@ -49,7 +49,7 @@ export const useMoveSoftFocusToCurrentCellOnHover = () => { } }, [ - getCurrentTableCellInEditModePositionState, + currentTableCellInEditModePositionState, isTableCellInEditModeFamilyState, setSoftFocusOnCurrentTableCell, ], diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell.ts index a880366cad89..12c2ecc6edb9 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCell.ts @@ -11,6 +11,7 @@ import { useLeaveTableFocus } from '@/object-record/record-table/hooks/internal/ import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { isDefined } from '~/utils/isDefined'; import { CellHotkeyScopeContext } from '../../contexts/CellHotkeyScopeContext'; import { TableHotkeyScope } from '../../types/TableHotkeyScope'; @@ -22,7 +23,7 @@ export const DEFAULT_CELL_SCOPE: HotkeyScope = { }; export const useOpenRecordTableCell = () => { - const { pathToShowPage } = useContext(RecordTableRowContext); + const { pathToShowPage, isReadOnly } = useContext(RecordTableRowContext); const { setCurrentTableCellInEditMode } = useCurrentTableCellEditMode(); const setHotkeyScope = useSetHotkeyScope(); @@ -45,6 +46,10 @@ export const useOpenRecordTableCell = () => { const openTableCell = useRecoilCallback( () => (options?: { initialValue?: string }) => { + if (isReadOnly) { + return; + } + if (isFirstColumnCell && !isEmpty) { leaveTableFocus(); navigate(pathToShowPage); @@ -56,7 +61,7 @@ export const useOpenRecordTableCell = () => { initFieldInputDraftValue(options?.initialValue); - if (customCellHotkeyScope) { + if (isDefined(customCellHotkeyScope)) { setHotkeyScope( customCellHotkeyScope.scope, customCellHotkeyScope.customScopes, @@ -69,15 +74,16 @@ export const useOpenRecordTableCell = () => { } }, [ + isReadOnly, isFirstColumnCell, isEmpty, - leaveTableFocus, - navigate, - pathToShowPage, setDragSelectionStartEnabled, setCurrentTableCellInEditMode, initFieldInputDraftValue, customCellHotkeyScope, + leaveTableFocus, + navigate, + pathToShowPage, setHotkeyScope, ], ); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetSoftFocus.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetSoftFocus.ts index 9c2ef5f73a14..9b630f4e2f36 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetSoftFocus.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useSetSoftFocus.ts @@ -10,7 +10,7 @@ import { TableHotkeyScope } from '../../types/TableHotkeyScope'; export const useSetSoftFocus = () => { const setSoftFocusPosition = useSetSoftFocusPosition(); - const { getIsSoftFocusActiveState } = useRecordTableStates(); + const { isSoftFocusActiveState } = useRecordTableStates(); const setHotkeyScope = useSetHotkeyScope(); @@ -19,10 +19,10 @@ export const useSetSoftFocus = () => { (newPosition: TableCellPosition) => { setSoftFocusPosition(newPosition); - set(getIsSoftFocusActiveState(), true); + set(isSoftFocusActiveState, true); setHotkeyScope(TableHotkeyScope.TableSoftFocus); }, - [setSoftFocusPosition, getIsSoftFocusActiveState, setHotkeyScope], + [setSoftFocusPosition, isSoftFocusActiveState, setHotkeyScope], ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useUpsertRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useUpsertRecord.ts new file mode 100644 index 000000000000..230e4a5d321a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/hooks/useUpsertRecord.ts @@ -0,0 +1,41 @@ +import { useContext } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; +import { useRecordFieldInputStates } from '@/object-record/record-field/hooks/internal/useRecordFieldInputStates'; +import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { isDefined } from '~/utils/isDefined'; + +export const useUpsertRecord = () => { + const { entityId, fieldDefinition } = useContext(FieldContext); + + const { pendingRecordIdState } = useRecordTableStates(); + + const pendingRecordId = useRecoilValue(pendingRecordIdState); + const fieldName = fieldDefinition.metadata.fieldName; + const { getDraftValueSelector } = useRecordFieldInputStates( + `${entityId}-${fieldName}`, + ); + const draftValue = useRecoilValue(getDraftValueSelector()); + + const objectNameSingular = + fieldDefinition.metadata.objectMetadataNameSingular ?? ''; + const { createOneRecord } = useCreateOneRecord({ + objectNameSingular, + }); + + const upsertRecord = (persistField: () => void) => { + if (isDefined(pendingRecordId) && isDefined(draftValue)) { + createOneRecord({ + id: pendingRecordId, + name: draftValue, + position: 'first', + }); + } else if (!pendingRecordId) { + persistField(); + } + }; + + return { upsertRecord }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState.ts new file mode 100644 index 000000000000..2d19afefa08b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const hasUserSelectedAllRowsComponentState = + createComponentState({ + key: 'hasUserSelectedAllRowsFamilyState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState.ts new file mode 100644 index 000000000000..803921f1e27a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +export const isRowSelectedComponentFamilyState = createComponentFamilyState< + boolean, + string +>({ + key: 'isRowSelectedComponentFamilyState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowSelectedFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowSelectedFamilyStateScopeMap.ts deleted file mode 100644 index ebb2af412132..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-row/states/isRowSelectedFamilyStateScopeMap.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -export const isRowSelectedFamilyStateScopeMap = createFamilyStateScopeMap< - boolean, - string ->({ - key: 'isRowSelectedFamilyStateScopeMap', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext.ts index e25882196037..a4dcca144245 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext.ts @@ -1,10 +1,10 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; +import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; import { ColumnDefinition } from '../../types/ColumnDefinition'; -type RecordTableScopeInternalContextProps = StateScopeMapKey & { +type RecordTableScopeInternalContextProps = ComponentStateKey & { onColumnsChange: (columns: ColumnDefinition[]) => void; }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/availableTableColumnsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/availableTableColumnsComponentState.ts new file mode 100644 index 000000000000..d7fcfa0721ff --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/availableTableColumnsComponentState.ts @@ -0,0 +1,11 @@ +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { ColumnDefinition } from '../types/ColumnDefinition'; + +export const availableTableColumnsComponentState = createComponentState< + ColumnDefinition[] +>({ + key: 'availableTableColumnsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/availableTableColumnsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/availableTableColumnsStateScopeMap.ts deleted file mode 100644 index f60d3afb1f6b..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/availableTableColumnsStateScopeMap.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { ColumnDefinition } from '../types/ColumnDefinition'; - -export const availableTableColumnsStateScopeMap = createStateScopeMap< - ColumnDefinition[] ->({ - key: 'availableTableColumnsStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/currentTableCellInEditModePositionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/currentTableCellInEditModePositionComponentState.ts new file mode 100644 index 000000000000..88616eb1b404 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/currentTableCellInEditModePositionComponentState.ts @@ -0,0 +1,12 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { TableCellPosition } from '../types/TableCellPosition'; + +export const currentTableCellInEditModePositionComponentState = + createComponentState({ + key: 'currentTableCellInEditModePositionComponentState', + defaultValue: { + row: 0, + column: 1, + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/currentTableCellInEditModePositionStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/currentTableCellInEditModePositionStateScopeMap.ts deleted file mode 100644 index 873790029848..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/currentTableCellInEditModePositionStateScopeMap.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { TableCellPosition } from '../types/TableCellPosition'; - -export const currentTableCellInEditModePositionStateScopeMap = - createStateScopeMap({ - key: 'currentTableCellInEditModePositionStateScopeMap', - defaultValue: { - row: 0, - column: 1, - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableInitialLoadingComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableInitialLoadingComponentState.ts new file mode 100644 index 000000000000..01d7f280d96b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableInitialLoadingComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isRecordTableInitialLoadingComponentState = + createComponentState({ + key: 'isRecordTableInitialLoadingComponentState', + defaultValue: true, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableInitialLoadingStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableInitialLoadingStateScopeMap.ts deleted file mode 100644 index 6b320ced8a1f..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isRecordTableInitialLoadingStateScopeMap.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isRecordTableInitialLoadingStateScopeMap = - createStateScopeMap({ - key: 'isRecordTableInitialLoadingStateScopeMap', - defaultValue: true, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusActiveComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusActiveComponentState.ts new file mode 100644 index 000000000000..a9b726ddf4ba --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusActiveComponentState.ts @@ -0,0 +1,6 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const isSoftFocusActiveComponentState = createComponentState({ + key: 'isSoftFocusActiveComponentState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusActiveStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusActiveStateScopeMap.ts deleted file mode 100644 index 917733d87828..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusActiveStateScopeMap.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const isSoftFocusActiveStateScopeMap = createStateScopeMap({ - key: 'isSoftFocusActiveStateScopeMap', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState.ts new file mode 100644 index 000000000000..dd9023404bd2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusOnTableCellComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +import { TableCellPosition } from '../types/TableCellPosition'; + +export const isSoftFocusOnTableCellComponentFamilyState = + createComponentFamilyState({ + key: 'isSoftFocusOnTableCellComponentFamilyState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusOnTableCellFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusOnTableCellFamilyStateScopeMap.ts deleted file mode 100644 index f9c48a851ffc..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusOnTableCellFamilyStateScopeMap.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -import { TableCellPosition } from '../types/TableCellPosition'; - -export const isSoftFocusOnTableCellFamilyStateScopeMap = - createFamilyStateScopeMap({ - key: 'isSoftFocusOnTableCellFamilyStateScopeMap', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusUsingMouseState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusUsingMouseState.ts index 7895269f933c..d25f46faa3b6 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusUsingMouseState.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isSoftFocusUsingMouseState.ts @@ -1,6 +1,6 @@ -import { atom } from 'recoil'; +import { createState } from 'twenty-ui'; -export const isSoftFocusUsingMouseState = atom({ +export const isSoftFocusUsingMouseState = createState({ key: 'isSoftFocusUsingMouseState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isTableCellInEditModeComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isTableCellInEditModeComponentFamilyState.ts new file mode 100644 index 000000000000..e049c0349719 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/isTableCellInEditModeComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; + +import { TableCellPosition } from '../types/TableCellPosition'; + +export const isTableCellInEditModeComponentFamilyState = + createComponentFamilyState({ + key: 'isTableCellInEditModeComponentFamilyState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/isTableCellInEditModeFamilyStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/isTableCellInEditModeFamilyStateScopeMap.ts deleted file mode 100644 index ff9ba4bd7130..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/isTableCellInEditModeFamilyStateScopeMap.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createFamilyStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createFamilyStateScopeMap'; - -import { TableCellPosition } from '../types/TableCellPosition'; - -export const isTableCellInEditModeFamilyStateScopeMap = - createFamilyStateScopeMap({ - key: 'isTableCellInEditModeFamilyStateScopeMap', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/numberOfTableRowsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/numberOfTableRowsComponentState.ts new file mode 100644 index 000000000000..2e6340e6d58d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/numberOfTableRowsComponentState.ts @@ -0,0 +1,6 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const numberOfTableRowsComponentState = createComponentState({ + key: 'numberOfTableRowsComponentState', + defaultValue: 0, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/numberOfTableRowsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/numberOfTableRowsStateScopeMap.ts deleted file mode 100644 index f67a8a40c434..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/numberOfTableRowsStateScopeMap.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const numberOfTableRowsStateScopeMap = createStateScopeMap({ - key: 'numberOfTableRowsStateScopeMap', - defaultValue: 0, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/onColumnsChangeComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/onColumnsChangeComponentState.ts new file mode 100644 index 000000000000..b157eb071d50 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/onColumnsChangeComponentState.ts @@ -0,0 +1,10 @@ +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const onColumnsChangeComponentState = createComponentState< + ((columns: ColumnDefinition[]) => void) | undefined +>({ + key: 'onColumnsChangeComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/onColumnsChangeStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/onColumnsChangeStateScopeMap.ts deleted file mode 100644 index 9253e244357e..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/onColumnsChangeStateScopeMap.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const onColumnsChangeStateScopeMap = createStateScopeMap< - ((columns: ColumnDefinition[]) => void) | undefined ->({ - key: 'onColumnsChangeStateScopeMap', - defaultValue: undefined, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/onEntityCountChangeComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/onEntityCountChangeComponentState.ts new file mode 100644 index 000000000000..65356e5dc7e8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/onEntityCountChangeComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const onEntityCountChangeComponentState = createComponentState< + ((entityCount: number) => void) | undefined +>({ + key: 'onEntityCountChangeComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/onEntityCountChangeStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/onEntityCountChangeStateScopeMap.ts deleted file mode 100644 index 6aefeedb7b2f..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/onEntityCountChangeStateScopeMap.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const onEntityCountChangeStateScopeMap = createStateScopeMap< - ((entityCount: number) => void) | undefined ->({ - key: 'onEntityCountChangeStateScopeMap', - defaultValue: undefined, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/onToggleColumnFilterComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/onToggleColumnFilterComponentState.ts new file mode 100644 index 000000000000..75a602331c73 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/onToggleColumnFilterComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const onToggleColumnFilterComponentState = createComponentState< + ((fieldMetadataId: string) => void) | undefined +>({ + key: 'onToggleColumnFilterComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/onToggleColumnSortComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/onToggleColumnSortComponentState.ts new file mode 100644 index 000000000000..73f0a924a94a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/onToggleColumnSortComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const onToggleColumnSortComponentState = createComponentState< + ((fieldMetadataId: string) => void) | undefined +>({ + key: 'onToggleColumnSortComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/recordTablePendingRecordIdComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/recordTablePendingRecordIdComponentState.ts new file mode 100644 index 000000000000..4e594933f8d5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/recordTablePendingRecordIdComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordTablePendingRecordIdComponentState = createComponentState< + string | null +>({ + key: 'recordTablePendingRecordIdState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/resizeFieldOffsetComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/resizeFieldOffsetComponentState.ts new file mode 100644 index 000000000000..9e2700f4596e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/resizeFieldOffsetComponentState.ts @@ -0,0 +1,6 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const resizeFieldOffsetComponentState = createComponentState({ + key: 'resizeFieldOffsetComponentState', + defaultValue: 0, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/resizeFieldOffsetStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/resizeFieldOffsetStateScopeMap.ts deleted file mode 100644 index 21ca29d2d422..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/resizeFieldOffsetStateScopeMap.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const resizeFieldOffsetStateScopeMap = createStateScopeMap({ - key: 'resizeFieldOffsetStateScopeMap', - defaultValue: 0, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts new file mode 100644 index 000000000000..c0a42ea81015 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusComponentSelector.ts @@ -0,0 +1,30 @@ +import { numberOfTableRowsComponentState } from '@/object-record/record-table/states/numberOfTableRowsComponentState'; +import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; + +import { AllRowsSelectedStatus } from '../../types/AllRowSelectedStatus'; + +export const allRowsSelectedStatusComponentSelector = + createComponentReadOnlySelector({ + key: 'allRowsSelectedStatusComponentSelector', + get: + ({ scopeId }) => + ({ get }) => { + const numberOfRows = get(numberOfTableRowsComponentState({ scopeId })); + + const selectedRowIds = get( + selectedRowIdsComponentSelector({ scopeId }), + ); + + const numberOfSelectedRows = selectedRowIds.length; + + const allRowsSelectedStatus = + numberOfSelectedRows === 0 + ? 'none' + : numberOfRows === numberOfSelectedRows + ? 'all' + : 'some'; + + return allRowsSelectedStatus; + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusSelectorScopeMap.ts deleted file mode 100644 index ee0a66e804c9..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/allRowsSelectedStatusSelectorScopeMap.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -import { AllRowsSelectedStatus } from '../../types/AllRowSelectedStatus'; -import { numberOfTableRowsStateScopeMap } from '../numberOfTableRowsStateScopeMap'; - -import { selectedRowIdsSelectorScopeMap } from './selectedRowIdsSelectorScopeMap'; - -export const allRowsSelectedStatusSelectorScopeMap = - createSelectorReadOnlyScopeMap({ - key: 'allRowsSelectedStatusSelectorScopeMap', - get: - ({ scopeId }) => - ({ get }) => { - const numberOfRows = get(numberOfTableRowsStateScopeMap({ scopeId })); - - const selectedRowIds = get(selectedRowIdsSelectorScopeMap({ scopeId })); - - const numberOfSelectedRows = selectedRowIds.length; - - const allRowsSelectedStatus = - numberOfSelectedRows === 0 - ? 'none' - : numberOfRows === numberOfSelectedRows - ? 'all' - : 'some'; - - return allRowsSelectedStatus; - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector.ts new file mode 100644 index 000000000000..c6692670d42a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector.ts @@ -0,0 +1,38 @@ +import { availableTableColumnsComponentState } from '@/object-record/record-table/states/availableTableColumnsComponentState'; +import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; +import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; + +export const hiddenTableColumnsComponentSelector = + createComponentReadOnlySelector({ + key: 'hiddenTableColumnsComponentSelector', + get: + ({ scopeId }) => + ({ get }) => { + const tableColumns = get(tableColumnsComponentState({ scopeId })); + const availableColumns = get( + availableTableColumnsComponentState({ scopeId }), + ); + const tableColumnsByKey = mapArrayToObject( + tableColumns, + ({ fieldMetadataId }) => fieldMetadataId, + ); + + const hiddenColumns = availableColumns + .filter( + ({ fieldMetadataId }) => + !tableColumnsByKey[fieldMetadataId]?.isVisible, + ) + .map((availableColumn) => { + const { fieldMetadataId } = availableColumn; + const existingTableColumn = tableColumnsByKey[fieldMetadataId]; + + return { + ...(existingTableColumn || availableColumn), + isVisible: false, + }; + }); + + return hiddenColumns; + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/hiddenTableColumnsSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/hiddenTableColumnsSelectorScopeMap.ts deleted file mode 100644 index 1fcd14587ede..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/hiddenTableColumnsSelectorScopeMap.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { availableTableColumnsStateScopeMap } from '@/object-record/record-table/states/availableTableColumnsStateScopeMap'; -import { tableColumnsStateScopeMap } from '@/object-record/record-table/states/tableColumnsStateScopeMap'; -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; -import { mapArrayToObject } from '~/utils/array/mapArrayToObject'; - -export const hiddenTableColumnsSelectorScopeMap = - createSelectorReadOnlyScopeMap({ - key: 'hiddenTableColumnsSelectorScopeMap', - get: - ({ scopeId }) => - ({ get }) => { - const tableColumns = get(tableColumnsStateScopeMap({ scopeId })); - const availableColumns = get( - availableTableColumnsStateScopeMap({ scopeId }), - ); - const tableColumnsByKey = mapArrayToObject( - tableColumns, - ({ fieldMetadataId }) => fieldMetadataId, - ); - - const hiddenColumns = availableColumns - .filter( - ({ fieldMetadataId }) => - !tableColumnsByKey[fieldMetadataId]?.isVisible, - ) - .map((availableColumn) => { - const { fieldMetadataId } = availableColumn; - const existingTableColumn = tableColumnsByKey[fieldMetadataId]; - - return { - ...(existingTableColumn || availableColumn), - isVisible: false, - }; - }); - - return hiddenColumns; - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/numberOfTableColumnsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/numberOfTableColumnsComponentSelector.ts new file mode 100644 index 000000000000..83754490c117 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/numberOfTableColumnsComponentSelector.ts @@ -0,0 +1,11 @@ +import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; + +export const numberOfTableColumnsComponentSelector = + createComponentReadOnlySelector({ + key: 'numberOfTableColumnsComponentSelector', + get: + ({ scopeId }) => + ({ get }) => + get(tableColumnsComponentState({ scopeId })).length, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/numberOfTableColumnsSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/numberOfTableColumnsSelectorScopeMap.ts deleted file mode 100644 index a4afd9ccbff7..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/numberOfTableColumnsSelectorScopeMap.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -import { tableColumnsStateScopeMap } from '../tableColumnsStateScopeMap'; - -export const numberOfTableColumnsSelectorScopeMap = - createSelectorReadOnlyScopeMap({ - key: 'numberOfTableColumnsSelectorScopeMap', - get: - ({ scopeId }) => - ({ get }) => - get(tableColumnsStateScopeMap({ scopeId })).length, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/selectedRowIdsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/selectedRowIdsComponentSelector.ts new file mode 100644 index 000000000000..3a3a020c89b4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/selectedRowIdsComponentSelector.ts @@ -0,0 +1,24 @@ +import { isRowSelectedComponentFamilyState } from '@/object-record/record-table/record-table-row/states/isRowSelectedComponentFamilyState'; +import { tableRowIdsComponentState } from '@/object-record/record-table/states/tableRowIdsComponentState'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; + +export const selectedRowIdsComponentSelector = createComponentReadOnlySelector< + string[] +>({ + key: 'selectedRowIdsComponentSelector', + get: + ({ scopeId }) => + ({ get }) => { + const rowIds = get(tableRowIdsComponentState({ scopeId })); + + return rowIds.filter( + (rowId) => + get( + isRowSelectedComponentFamilyState({ + scopeId, + familyKey: rowId, + }), + ) === true, + ); + }, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/selectedRowIdsSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/selectedRowIdsSelectorScopeMap.ts deleted file mode 100644 index b395c54d3dc4..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/selectedRowIdsSelectorScopeMap.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -import { isRowSelectedFamilyStateScopeMap } from '../../record-table-row/states/isRowSelectedFamilyStateScopeMap'; -import { tableRowIdsStateScopeMap } from '../tableRowIdsStateScopeMap'; - -export const selectedRowIdsSelectorScopeMap = createSelectorReadOnlyScopeMap< - string[] ->({ - key: 'selectedRowIdsSelectorScopeMap', - get: - ({ scopeId }) => - ({ get }) => { - const rowIds = get(tableRowIdsStateScopeMap({ scopeId })); - - return rowIds.filter( - (rowId) => - get( - isRowSelectedFamilyStateScopeMap({ - scopeId, - familyKey: rowId, - }), - ) === true, - ); - }, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector.ts new file mode 100644 index 000000000000..fc58195cad52 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector.ts @@ -0,0 +1,15 @@ +import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; +import { createComponentReadOnlySelector } from '@/ui/utilities/state/component-state/utils/createComponentReadOnlySelector'; + +export const visibleTableColumnsComponentSelector = + createComponentReadOnlySelector({ + key: 'visibleTableColumnsComponentSelector', + get: + ({ scopeId }) => + ({ get }) => { + const columns = get(tableColumnsComponentState({ scopeId })); + return columns + .filter((column) => column.isVisible) + .sort((columnA, columnB) => columnA.position - columnB.position); + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/visibleTableColumnsSelectorScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/selectors/visibleTableColumnsSelectorScopeMap.ts deleted file mode 100644 index 01a818edb610..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/selectors/visibleTableColumnsSelectorScopeMap.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { tableColumnsStateScopeMap } from '@/object-record/record-table/states/tableColumnsStateScopeMap'; -import { createSelectorReadOnlyScopeMap } from '@/ui/utilities/recoil-scope/utils/createSelectorReadOnlyScopeMap'; - -export const visibleTableColumnsSelectorScopeMap = - createSelectorReadOnlyScopeMap({ - key: 'visibleTableColumnsSelectorScopeMap', - get: - ({ scopeId }) => - ({ get }) => { - const columns = get(tableColumnsStateScopeMap({ scopeId })); - return columns - .filter((column) => column.isVisible) - .sort((columnA, columnB) => columnA.position - columnB.position); - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/softFocusPositionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/softFocusPositionComponentState.ts new file mode 100644 index 000000000000..212b581cc7a0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/softFocusPositionComponentState.ts @@ -0,0 +1,12 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { TableCellPosition } from '../types/TableCellPosition'; + +export const softFocusPositionComponentState = + createComponentState({ + key: 'softFocusPositionComponentState', + defaultValue: { + row: 0, + column: 1, + }, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/softFocusPositionStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/softFocusPositionStateScopeMap.ts deleted file mode 100644 index 5a4b87fb96a4..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/softFocusPositionStateScopeMap.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { TableCellPosition } from '../types/TableCellPosition'; - -export const softFocusPositionStateScopeMap = - createStateScopeMap({ - key: 'softFocusPositionStateScopeMap', - defaultValue: { - row: 0, - column: 1, - }, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableColumnsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableColumnsComponentState.ts new file mode 100644 index 000000000000..8c89c297259f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/tableColumnsComponentState.ts @@ -0,0 +1,11 @@ +import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { ColumnDefinition } from '../types/ColumnDefinition'; + +export const tableColumnsComponentState = createComponentState< + ColumnDefinition[] +>({ + key: 'tableColumnsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableColumnsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableColumnsStateScopeMap.ts deleted file mode 100644 index d04192bb62e3..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/tableColumnsStateScopeMap.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { ColumnDefinition } from '../types/ColumnDefinition'; - -export const tableColumnsStateScopeMap = createStateScopeMap< - ColumnDefinition[] ->({ - key: 'tableColumnsStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableFiltersComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableFiltersComponentState.ts new file mode 100644 index 000000000000..d19698d0f90a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/tableFiltersComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { Filter } from '../../object-filter-dropdown/types/Filter'; + +export const tableFiltersComponentState = createComponentState({ + key: 'tableFiltersComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableFiltersStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableFiltersStateScopeMap.ts deleted file mode 100644 index 87c37e6f9a8b..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/tableFiltersStateScopeMap.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { Filter } from '../../object-filter-dropdown/types/Filter'; - -export const tableFiltersStateScopeMap = createStateScopeMap({ - key: 'tableFiltersStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableLastRowVisibleComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableLastRowVisibleComponentState.ts new file mode 100644 index 000000000000..371cd72d6a05 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/tableLastRowVisibleComponentState.ts @@ -0,0 +1,6 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const tableLastRowVisibleComponentState = createComponentState({ + key: 'tableLastRowVisibleComponentState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableLastRowVisibleStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableLastRowVisibleStateScopeMap.ts deleted file mode 100644 index 8648e7bbefd5..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/tableLastRowVisibleStateScopeMap.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const tableLastRowVisibleStateScopeMap = createStateScopeMap({ - key: 'tableLastRowVisibleStateScopeMap', - defaultValue: false, -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableRowIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableRowIdsComponentState.ts new file mode 100644 index 000000000000..5350c7f448da --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/tableRowIdsComponentState.ts @@ -0,0 +1,6 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const tableRowIdsComponentState = createComponentState({ + key: 'tableRowIdsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableRowIdsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableRowIdsStateScopeMap.ts deleted file mode 100644 index a9027a4edc3b..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/tableRowIdsStateScopeMap.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -export const tableRowIdsStateScopeMap = createStateScopeMap({ - key: 'tableRowIdsStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableSortsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableSortsComponentState.ts new file mode 100644 index 000000000000..e36730a2819b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/states/tableSortsComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +import { Sort } from '../../object-sort-dropdown/types/Sort'; + +export const tableSortsComponentState = createComponentState({ + key: 'tableSortsComponentState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/states/tableSortsStateScopeMap.ts b/packages/twenty-front/src/modules/object-record/record-table/states/tableSortsStateScopeMap.ts deleted file mode 100644 index 2e1900a2b7d4..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/states/tableSortsStateScopeMap.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; - -import { Sort } from '../../object-sort-dropdown/types/Sort'; - -export const tableSortsStateScopeMap = createStateScopeMap({ - key: 'tableSortsStateScopeMap', - defaultValue: [], -}); diff --git a/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts b/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts index d65d838e495d..c14b745ef541 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/types/ColumnDefinition.ts @@ -7,4 +7,6 @@ export type ColumnDefinition = FieldDefinition & { isLabelIdentifier?: boolean; isVisible?: boolean; viewFieldId?: string; + isFilterable?: boolean; + isSortable?: boolean; }; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx index 05aaf85f56ae..44932a8ea774 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/MultipleObjectRecordSelect.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; -import debounce from 'lodash.debounce'; +import { useDebouncedCallback } from 'use-debounce'; import { MultipleObjectRecordOnClickOutsideEffect } from '@/object-record/relation-picker/components/MultipleObjectRecordOnClickOutsideEffect'; import { MultipleObjectRecordSelectItem } from '@/object-record/relation-picker/components/MultipleObjectRecordSelectItem'; @@ -19,6 +19,7 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { isDefined } from '~/utils/isDefined'; export const StyledSelectableItem = styled(SelectableItem)` height: 100%; @@ -82,7 +83,7 @@ export const MultipleObjectRecordSelect = ({ } }, [selectedObjectRecordsForSelect, loading]); - const debouncedSetSearchFilter = debounce(setSearchFilter, 100, { + const debouncedSetSearchFilter = useDebouncedCallback(setSearchFilter, 100, { leading: true, }); @@ -152,7 +153,7 @@ export const MultipleObjectRecordSelect = ({ (entity) => entity.record.id === recordId, ); - if (correspondingRecordForSelect) { + if (isDefined(correspondingRecordForSelect)) { handleSelectChange( correspondingRecordForSelect, !recordIsSelected, diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/RelationPicker.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/RelationPicker.tsx index 2585125ade6d..d5e4ef29c464 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/RelationPicker.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/RelationPicker.tsx @@ -1,11 +1,11 @@ import { useEffect } from 'react'; +import { IconForbid } from 'twenty-ui'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; -import { IconForbid } from '@/ui/display/icon'; export type RelationPickerProps = { recordId?: string; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelect.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelect.tsx index e0b8d30d9613..368636fb470f 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelect.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelect.tsx @@ -6,6 +6,7 @@ import { } from '@/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { isDefined } from '~/utils/isDefined'; export type SingleEntitySelectProps = { disableBackgroundBlur?: boolean; @@ -37,7 +38,7 @@ export const SingleEntitySelect = ({ event.target instanceof HTMLInputElement && event.target.tagName === 'INPUT' ); - if (weAreNotInAnHTMLInput && onCancel) { + if (weAreNotInAnHTMLInput && isDefined(onCancel)) { onCancel(); } }, diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx index 8273f15f81bf..a71fa24a9622 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItems.tsx @@ -1,9 +1,9 @@ import { useRef } from 'react'; import { isNonEmptyString } from '@sniptt/guards'; import { Key } from 'ts-key-enum'; +import { IconPlus } from 'twenty-ui'; import { SelectableMenuItemSelect } from '@/object-record/relation-picker/components/SelectableMenuItemSelect'; -import { IconPlus } from '@/ui/display/icon'; import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { CreateNewButton } from '@/ui/input/relation-picker/components/CreateNewButton'; import { DropdownMenuSkeletonItem } from '@/ui/input/relation-picker/components/skeletons/DropdownMenuSkeletonItem'; @@ -13,7 +13,7 @@ import { SelectableList } from '@/ui/layout/selectable-list/components/Selectabl import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { EntityForSelect } from '../types/EntityForSelect'; import { RelationPickerHotkeyScope } from '../types/RelationPickerHotkeyScope'; @@ -55,7 +55,7 @@ export const SingleEntitySelectMenuItems = ({ const entitiesInDropdown = [selectedEntity, ...entitiesToSelect].filter( (entity): entity is EntityForSelect => - isNonNullable(entity) && isNonEmptyString(entity.name), + isDefined(entity) && isNonEmptyString(entity.name), ); useScopedHotkeys( @@ -76,7 +76,7 @@ export const SingleEntitySelectMenuItems = ({ selectableItemIdArray={selectableItemIds} hotkeyScope={RelationPickerHotkeyScope.RelationPicker} onEnter={(itemId) => { - if (showCreateButton) { + if (showCreateButton === true) { onCreate?.(); } else { const entity = entitiesInDropdown.findIndex( diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx index d2b814237ca9..c24c35aee8b1 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/SingleEntitySelectMenuItemsWithSearch.tsx @@ -6,7 +6,7 @@ import { import { useFilteredSearchEntityQuery } from '@/search/hooks/useFilteredSearchEntityQuery'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { useEntitySelectSearch } from '../hooks/useEntitySelectSearch'; @@ -42,7 +42,7 @@ export const SingleEntitySelectMenuItemsWithSearch = ({ relationPickerScopeId, }); - const showCreateButton = isNonNullable(onCreate) && searchFilter !== ''; + const showCreateButton = isDefined(onCreate) && searchFilter !== ''; const entities = useFilteredSearchEntityQuery({ filters: [ diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/components/__stories__/SingleEntitySelect.stories.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/components/__stories__/SingleEntitySelect.stories.tsx index 968c23418aa0..07a32ade6d37 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/components/__stories__/SingleEntitySelect.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/components/__stories__/SingleEntitySelect.stories.tsx @@ -1,8 +1,8 @@ import { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; +import { IconUserCircle } from 'twenty-ui'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { IconUserCircle } from '@/ui/display/icon'; import { ComponentDecorator } from '~/testing/decorators/ComponentDecorator'; import { ComponentWithRecoilScopeDecorator } from '~/testing/decorators/ComponentWithRecoilScopeDecorator'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx index a117719c04bc..fa85d8629076 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/__tests__/useMultiObjectSearch.test.tsx @@ -6,13 +6,14 @@ import { RecoilRoot, useSetRecoilState } from 'recoil'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useMultiObjectSearch } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { FieldMetadataType } from '~/generated/graphql'; const query = gql` query FindManyRecordsMultipleMetadataItems( $filterNameSingular: NameSingularFilterInput $orderByNameSingular: NameSingularOrderByInput $lastCursorNameSingular: String - $limitNameSingular: Float = 5 + $limitNameSingular: Float ) { namePlural( filter: $filterNameSingular @@ -22,6 +23,7 @@ const query = gql` ) { edges { node { + __typename id } cursor @@ -31,12 +33,13 @@ const query = gql` startCursor endCursor } + totalCount } } `; const response = { namePlural: { - edges: [{ node: { id: 'nodeId' }, cursor: 'cursor' }], + edges: [{ node: { __typename: 'Custom', id: 'nodeId' }, cursor: 'cursor' }], pageInfo: { startCursor: '', hasNextPage: '', endCursor: '' }, }, }; @@ -120,7 +123,17 @@ describe('useMultiObjectSearch', () => { namePlural: 'namePlural', nameSingular: 'nameSingular', updatedAt: 'updatedAt', - fields: [], + fields: [ + { + id: 'f6a0a73a-5ee6-442e-b764-39b682471240', + name: 'id', + label: 'id', + type: FieldMetadataType.Uuid, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + isActive: true, + }, + ], }, ]; act(() => { @@ -144,9 +157,19 @@ describe('useMultiObjectSearch', () => { namePlural: 'namePlural', nameSingular: 'nameSingular', updatedAt: 'updatedAt', - fields: [], + fields: [ + { + id: 'f6a0a73a-5ee6-442e-b764-39b682471240', + name: 'id', + label: 'id', + isActive: true, + type: FieldMetadataType.Uuid, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ], }, - record: { id: 'nodeId' }, + record: { id: 'nodeId', __typename: 'Custom' }, recordIdentifier: { id: 'nodeId', name: '', diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useEntitySelectSearch.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useEntitySelectSearch.ts index d81cd060d556..be2e6e561034 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useEntitySelectSearch.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useEntitySelectSearch.ts @@ -1,4 +1,4 @@ -import debounce from 'lodash.debounce'; +import { useDebouncedCallback } from 'use-debounce'; import { useRelationPicker } from '@/object-record/relation-picker/hooks/useRelationPicker'; @@ -14,7 +14,7 @@ export const useEntitySelectSearch = ({ setRelationPickerSearchFilter, } = useRelationPicker({ relationPickerScopeId }); - const debouncedSetSearchFilter = debounce( + const debouncedSetSearchFilter = useDebouncedCallback( setRelationPickerSearchFilter, 100, { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts index 29602a6a4113..95be7ef2b64a 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useLimitPerMetadataItem.ts @@ -1,6 +1,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const useLimitPerMetadataItem = ({ @@ -15,7 +15,7 @@ export const useLimitPerMetadataItem = ({ .map(({ nameSingular }) => { return [`limit${capitalize(nameSingular)}`, limit]; }) - .filter(isNonNullable), + .filter(isDefined), ); return { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts index 6091f2b85844..eedf253275c8 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray.ts @@ -5,7 +5,7 @@ import { objectMetadataItemsByNamePluralMapSelector } from '@/object-metadata/st import { getObjectRecordIdentifier } from '@/object-metadata/utils/getObjectRecordIdentifier'; import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { ObjectRecordConnection } from '@/object-record/types/ObjectRecordConnection'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export type MultiObjectRecordQueryResult = { [namePlural: string]: ObjectRecordConnection; @@ -30,7 +30,7 @@ export const useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArr const objectMetadataItem = objectMetadataItemsByNamePluralMap.get(namePlural); - if (!isNonNullable(objectMetadataItem)) return []; + if (!isDefined(objectMetadataItem)) return []; return objectRecordConnection.edges.map(({ node }) => ({ objectMetadataItem, diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts index 635f2e7546d1..a8cd2b5094b9 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery.ts @@ -2,9 +2,9 @@ import { useQuery } from '@apollo/client'; import { isNonEmptyArray } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; -import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; import { MultiObjectRecordQueryResult, @@ -13,7 +13,7 @@ import { import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ @@ -71,7 +71,7 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ }, ]; }) - .filter(isNonNullable), + .filter(isDefined), ); const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ @@ -85,7 +85,8 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ const multiSelectQueryForSelectedIds = useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ - objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + targetObjectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + depth: 0, }); const { @@ -99,7 +100,7 @@ export const useMultiObjectSearchMatchesSearchFilterAndSelectedItemsQuery = ({ ...orderByFieldPerMetadataItem, ...limitPerMetadataItem, }, - skip: !isNonNullable(multiSelectQueryForSelectedIds), + skip: !isDefined(multiSelectQueryForSelectedIds), }, ); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts index 538b6191984b..8719c81fdf3d 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchMatchesSearchFilterAndToSelectQuery.ts @@ -1,10 +1,10 @@ import { useQuery } from '@apollo/client'; import { useRecoilValue } from 'recoil'; -import { EMPTY_QUERY } from '@/object-metadata/hooks/useObjectMetadataItem'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; import { MultiObjectRecordQueryResult, @@ -14,7 +14,7 @@ import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/us import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; import { useSearchFilterPerMetadataItem } from '@/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ @@ -72,7 +72,7 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ makeAndFilterVariables(searchFilters), ]; }) - .filter(isNonNullable), + .filter(isDefined), ); const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ @@ -86,7 +86,8 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ const multiSelectQuery = useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ - objectMetadataItems: nonSystemObjectMetadataItems, + targetObjectMetadataItems: nonSystemObjectMetadataItems, + depth: 0, }); const { @@ -98,7 +99,7 @@ export const useMultiObjectSearchMatchesSearchFilterAndToSelectQuery = ({ ...orderByFieldPerMetadataItem, ...limitPerMetadataItem, }, - skip: !isNonNullable(multiSelectQuery), + skip: !isDefined(multiSelectQuery), }); const { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts index 2c18f7d3bae2..155b584c3a60 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useMultiObjectSearchSelectedItemsQuery.ts @@ -1,10 +1,9 @@ -import { useQuery } from '@apollo/client'; -import { gql } from '@apollo/client'; +import { gql, useQuery } from '@apollo/client'; import { isNonEmptyArray } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; -import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; +import { useGenerateFindManyRecordsForMultipleMetadataItemsQuery } from '@/object-record/multiple-objects/hooks/useGenerateFindManyRecordsForMultipleMetadataItemsQuery'; import { useLimitPerMetadataItem } from '@/object-record/relation-picker/hooks/useLimitPerMetadataItem'; import { MultiObjectRecordQueryResult, @@ -12,7 +11,7 @@ import { } from '@/object-record/relation-picker/hooks/useMultiObjectRecordsQueryResultFormattedAsObjectRecordForSelectArray'; import { SelectedObjectRecordId } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { useOrderByFieldPerMetadataItem } from '@/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const EMPTY_QUERY = gql` @@ -56,7 +55,7 @@ export const useMultiObjectSearchSelectedItemsQuery = ({ }, ]; }) - .filter(isNonNullable), + .filter(isDefined), ); const { orderByFieldPerMetadataItem } = useOrderByFieldPerMetadataItem({ @@ -69,7 +68,7 @@ export const useMultiObjectSearchSelectedItemsQuery = ({ const multiSelectQueryForSelectedIds = useGenerateFindManyRecordsForMultipleMetadataItemsQuery({ - objectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, + targetObjectMetadataItems: objectMetadataItemsUsedInSelectedIdsQuery, }); const { @@ -83,7 +82,7 @@ export const useMultiObjectSearchSelectedItemsQuery = ({ ...orderByFieldPerMetadataItem, ...limitPerMetadataItem, }, - skip: !isNonNullable(multiSelectQueryForSelectedIds), + skip: !isDefined(multiSelectQueryForSelectedIds), }, ); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts index c04f650cd52e..07f60b012fce 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useOrderByFieldPerMetadataItem.ts @@ -1,6 +1,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { getObjectOrderByField } from '@/object-metadata/utils/getObjectOrderByField'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { getOrderByFieldForObjectMetadataItem } from '@/object-metadata/utils/getObjectOrderByField'; +import { isDefined } from '~/utils/isDefined'; import { capitalize } from '~/utils/string/capitalize'; export const useOrderByFieldPerMetadataItem = ({ @@ -11,7 +11,8 @@ export const useOrderByFieldPerMetadataItem = ({ const orderByFieldPerMetadataItem = Object.fromEntries( objectMetadataItems .map((objectMetadataItem) => { - const orderByField = getObjectOrderByField(objectMetadataItem); + const orderByField = + getOrderByFieldForObjectMetadataItem(objectMetadataItem); return [ `orderBy${capitalize(objectMetadataItem.nameSingular)}`, @@ -20,7 +21,7 @@ export const useOrderByFieldPerMetadataItem = ({ }, ]; }) - .filter(isNonNullable), + .filter(isDefined), ); return { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts index 0242a920258f..6a342b92d284 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/hooks/useSearchFilterPerMetadataItem.ts @@ -1,9 +1,11 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem'; import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; import { FieldMetadataType } from '~/generated/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const useSearchFilterPerMetadataItem = ({ objectMetadataItems, @@ -23,10 +25,10 @@ export const useSearchFilterPerMetadataItem = ({ let searchFilter: ObjectRecordQueryFilter = {}; - if (labelIdentifierFieldMetadataItem) { + if (isDefined(labelIdentifierFieldMetadataItem)) { switch (labelIdentifierFieldMetadataItem.type) { case FieldMetadataType.FullName: { - if (searchFilterValue) { + if (isNonEmptyString(searchFilterValue)) { const fullNameFilter = makeOrFilterVariables([ { [labelIdentifierFieldMetadataItem.name]: { @@ -44,14 +46,14 @@ export const useSearchFilterPerMetadataItem = ({ }, ]); - if (fullNameFilter) { + if (isDefined(fullNameFilter)) { searchFilter = fullNameFilter; } } break; } default: { - if (searchFilterValue) { + if (isNonEmptyString(searchFilterValue)) { searchFilter = { [labelIdentifierFieldMetadataItem.name]: { ilike: `%${searchFilterValue}%`, @@ -64,7 +66,7 @@ export const useSearchFilterPerMetadataItem = ({ return [objectMetadataItem.nameSingular, searchFilter] as const; }) - .filter(isNonNullable), + .filter(isDefined), ); return { diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext.ts index 8e49b1267aa3..3dbbfc7eff08 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext.ts @@ -1,7 +1,7 @@ -import { StateScopeMapKey } from '@/ui/utilities/recoil-scope/scopes-internal/types/StateScopeMapKey'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; +import { ComponentStateKey } from '@/ui/utilities/state/component-state/types/ComponentStateKey'; -type RelationPickerScopeInternalContextProps = StateScopeMapKey; +type RelationPickerScopeInternalContextProps = ComponentStateKey; export const RelationPickerScopeInternalContext = createScopeInternalContext(); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerPreselectedIdScopedState.ts b/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerPreselectedIdScopedState.ts index 2266afc5b338..c0c2ba232dd7 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerPreselectedIdScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerPreselectedIdScopedState.ts @@ -1,6 +1,6 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -export const relationPickerPreselectedIdScopedState = createStateScopeMap< +export const relationPickerPreselectedIdScopedState = createComponentState< string | undefined >({ key: 'relationPickerPreselectedIdScopedState', diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerSearchFilterScopedState.ts b/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerSearchFilterScopedState.ts index 5184ff81074a..60841557533b 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerSearchFilterScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/states/relationPickerSearchFilterScopedState.ts @@ -1,7 +1,7 @@ -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; export const relationPickerSearchFilterScopedState = - createStateScopeMap({ + createComponentState({ key: 'relationPickerSearchFilterScopedState', defaultValue: '', }); diff --git a/packages/twenty-front/src/modules/object-record/relation-picker/states/searchQueryScopedState.ts b/packages/twenty-front/src/modules/object-record/relation-picker/states/searchQueryScopedState.ts index 5f94b225a2da..70a072e0f798 100644 --- a/packages/twenty-front/src/modules/object-record/relation-picker/states/searchQueryScopedState.ts +++ b/packages/twenty-front/src/modules/object-record/relation-picker/states/searchQueryScopedState.ts @@ -1,7 +1,7 @@ import { SearchQuery } from '@/object-record/relation-picker/types/SearchQuery'; -import { createStateScopeMap } from '@/ui/utilities/recoil-scope/utils/createStateScopeMap'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -export const searchQueryScopedState = createStateScopeMap({ +export const searchQueryScopedState = createComponentState({ key: 'searchQueryScopedState', defaultValue: null, }); diff --git a/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts b/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts index ed72f8409a71..e12d3cec060b 100644 --- a/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts +++ b/packages/twenty-front/src/modules/object-record/select/hooks/useRecordsForSelect.ts @@ -1,6 +1,7 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useGetObjectOrderByField } from '@/object-metadata/hooks/useGetObjectOrderByField'; +import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { OrderBy } from '@/object-metadata/types/OrderBy'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -24,10 +25,9 @@ export const useRecordsForSelect = ({ excludeEntityIds?: string[]; objectNameSingular: string; }) => { - const { mapToObjectRecordIdentifier, getObjectOrderByField } = - useObjectMetadataItem({ - objectNameSingular, - }); + const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ + objectNameSingular, + }); const filters = [ { @@ -36,6 +36,10 @@ export const useRecordsForSelect = ({ }, ]; + const { getObjectOrderByField } = useGetObjectOrderByField({ + objectNameSingular, + }); + const orderByField = getObjectOrderByField(sortOrder); const selectedIdsFilter = { id: { in: selectedIds } }; @@ -56,7 +60,7 @@ export const useRecordsForSelect = ({ fieldNames.map((fieldName) => { const [parentFieldName, subFieldName] = fieldName.split('.'); - if (subFieldName) { + if (isNonEmptyString(subFieldName)) { // Composite field return { [parentFieldName]: { diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx b/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx index a26df2b1c81a..a925b36c8c74 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/__tests__/useSpreadsheetRecordImport.test.tsx @@ -16,17 +16,34 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => companyId), })); -jest.mock('@/object-metadata/hooks/useMapFieldMetadataToGraphQLQuery', () => ({ - useMapFieldMetadataToGraphQLQuery: () => () => '\n', -})); - const companyMocks = [ { request: { query: gql` mutation CreateCompanies($data: [CompanyCreateInput!]!) { createCompanies(data: $data) { + __typename + xLink { + label + url + } + linkedinLink { + label + url + } + domainName + annualRecurringRevenue { + amountMicros + currencyCode + } + createdAt + address + updatedAt + name + accountOwnerId + employees id + idealCustomerProfile } } `, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts index e2cc05e27c50..56453adf17ad 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/useSpreadsheetRecordImport.ts @@ -1,3 +1,5 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useCreateManyRecords } from '@/object-record/hooks/useCreateManyRecords'; import { getSpreadSheetValidation } from '@/object-record/spreadsheet-import/util/getSpreadSheetValidation'; @@ -7,6 +9,7 @@ import { useIcons } from '@/ui/display/icon/hooks/useIcons'; import { IconComponent } from '@/ui/display/icon/types/IconComponent'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { isDefined } from '~/utils/isDefined'; const firstName = 'Firstname'; const lastName = 'Lastname'; @@ -16,7 +19,9 @@ export const useSpreadsheetRecordImport = (objectNameSingular: string) => { const { enqueueSnackBar } = useSnackBar(); const { getIcon } = useIcons(); - const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular }); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); const fields = objectMetadataItem.fields .filter( (x) => @@ -128,14 +133,19 @@ export const useSpreadsheetRecordImport = (objectNameSingular: string) => { } break; case FieldMetadataType.Relation: - if (value) { + if ( + isDefined(value) && + (isNonEmptyString(value) || value !== false) + ) { fieldMapping[field.name + 'Id'] = value; } break; case FieldMetadataType.FullName: if ( - record[`${firstName} (${field.name})`] || - record[`${lastName} (${field.name})`] + isDefined( + record[`${firstName} (${field.name})`] || + record[`${lastName} (${field.name})`], + ) ) { fieldMapping[field.name] = { firstName: record[`${firstName} (${field.name})`] || '', diff --git a/packages/twenty-front/src/modules/object-record/states/cursorFamilyState.ts b/packages/twenty-front/src/modules/object-record/states/cursorFamilyState.ts index e9c44191e4b0..44fb5742cb57 100644 --- a/packages/twenty-front/src/modules/object-record/states/cursorFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/states/cursorFamilyState.ts @@ -1,6 +1,6 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const cursorFamilyState = atomFamily({ +export const cursorFamilyState = createFamilyState({ key: 'cursorFamilyState', - default: '', + defaultValue: '', }); diff --git a/packages/twenty-front/src/modules/object-record/states/hasNextPageFamilyState.ts b/packages/twenty-front/src/modules/object-record/states/hasNextPageFamilyState.ts index 20260b92788c..2a5f9e89478a 100644 --- a/packages/twenty-front/src/modules/object-record/states/hasNextPageFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/states/hasNextPageFamilyState.ts @@ -1,6 +1,9 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const hasNextPageFamilyState = atomFamily({ +export const hasNextPageFamilyState = createFamilyState< + boolean, + string | undefined +>({ key: 'hasNextPageFamilyState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/states/isFetchingMoreRecordsFamilyState.ts b/packages/twenty-front/src/modules/object-record/states/isFetchingMoreRecordsFamilyState.ts index 4137f67a710b..e254f92318c1 100644 --- a/packages/twenty-front/src/modules/object-record/states/isFetchingMoreRecordsFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/states/isFetchingMoreRecordsFamilyState.ts @@ -1,9 +1,9 @@ -import { atomFamily } from 'recoil'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; -export const isFetchingMoreRecordsFamilyState = atomFamily< +export const isFetchingMoreRecordsFamilyState = createFamilyState< boolean, string | undefined >({ key: 'isFetchingMoreRecordsFamilyState', - default: false, + defaultValue: false, }); diff --git a/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts b/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts index 52bb2160211d..ba79640f1fbb 100644 --- a/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts +++ b/packages/twenty-front/src/modules/object-record/types/ObjectRecordConnection.ts @@ -10,6 +10,7 @@ export type ObjectRecordConnection = { hasPreviousPage?: boolean; startCursor?: Nullable; endCursor?: Nullable; + totalCount?: number; }; - totalCount: number; + totalCount?: number; }; diff --git a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts index 5af6540aecbb..4df6d4afd655 100644 --- a/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts +++ b/packages/twenty-front/src/modules/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata.ts @@ -1,14 +1,18 @@ +import { IconPencil } from 'twenty-ui'; + import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; -import { IconPencil } from '@/ui/display/icon'; import { FieldMetadataType } from '~/generated-metadata/graphql'; export const computeRecordBoardColumnDefinitionsFromObjectMetadata = ( objectMetadataItem: ObjectMetadataItem, + kanbanFieldMetadataId: string, navigateToSelectSettings: () => void, ): RecordBoardColumnDefinition[] => { const selectFieldMetadataItem = objectMetadataItem.fields.find( - (field) => field.type === FieldMetadataType.Select, + (field) => + field.id === kanbanFieldMetadataId && + field.type === FieldMetadataType.Select, ); if (!selectFieldMetadataItem) { diff --git a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts index 176ccbad2867..bb7cc3578b3d 100644 --- a/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts +++ b/packages/twenty-front/src/modules/object-record/utils/filterAvailableTableColumns.ts @@ -16,12 +16,5 @@ export const filterAvailableTableColumns = ( return false; } - if ( - isFieldRelation(columnDefinition) && - columnDefinition.metadata?.fieldName === 'pipelineStep' - ) { - return false; - } - return true; }; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts b/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts deleted file mode 100644 index ad0a2840bf40..000000000000 --- a/packages/twenty-front/src/modules/object-record/utils/generateDeleteOneRecordMutation.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { gql } from '@apollo/client'; - -import { EMPTY_MUTATION } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; -import { capitalize } from '~/utils/string/capitalize'; - -export const getDeleteOneRecordMutationResponseField = ( - objectNameSingular: string, -) => `delete${capitalize(objectNameSingular)}`; - -export const generateDeleteOneRecordMutation = ({ - objectMetadataItem, -}: { - objectMetadataItem: ObjectMetadataItem; -}) => { - if (!objectMetadataItem) { - return EMPTY_MUTATION; - } - - const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular); - - const mutationResponseField = getDeleteOneRecordMutationResponseField( - objectMetadataItem.nameSingular, - ); - - return gql` - mutation DeleteOne${capitalizedObjectName}($idToDelete: ID!) { - ${mutationResponseField}(id: $idToDelete) { - id - } - } - `; -}; diff --git a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts index 1275379afd0d..95c05b0d0ac4 100644 --- a/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts +++ b/packages/twenty-front/src/modules/object-record/utils/generateEmptyFieldValue.ts @@ -2,7 +2,6 @@ import { isNonEmptyString } from '@sniptt/guards'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FieldMetadataType } from '~/generated/graphql'; -import { capitalize } from '~/utils/string/capitalize'; export const generateEmptyFieldValue = ( fieldMetadataItem: FieldMetadataItem, @@ -17,21 +16,35 @@ export const generateEmptyFieldValue = ( return { label: '', url: '', - __typename: 'Link', }; } case FieldMetadataType.FullName: { return { firstName: '', lastName: '', - __typename: 'FullName', + }; + } + case FieldMetadataType.Address: { + return { + addressStreet1: '', + addressStreet2: '', + addressCity: '', + addressState: '', + addressCountry: '', + addressPostcode: '', + addressLat: null, + addressLng: null, }; } case FieldMetadataType.DateTime: { return null; } + case FieldMetadataType.Date: { + return null; + } case FieldMetadataType.Number: case FieldMetadataType.Rating: + case FieldMetadataType.Position: case FieldMetadataType.Numeric: { return null; } @@ -51,25 +64,22 @@ export const generateEmptyFieldValue = ( return null; } - return { - __typename: `${capitalize( - fieldMetadataItem.fromRelationMetadata.toObjectMetadata.nameSingular, - )}Connection`, - edges: [], - }; + return []; } case FieldMetadataType.Currency: { return { amountMicros: null, currencyCode: null, - __typename: 'Currency', }; } case FieldMetadataType.Select: { return null; } case FieldMetadataType.MultiSelect: { - throw new Error('Not implemented yet'); + return null; + } + case FieldMetadataType.RawJson: { + return null; } default: { throw new Error('Unhandled FieldMetadataType'); diff --git a/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts new file mode 100644 index 000000000000..7a4f4507078b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/generateFindManyRecordsQuery.ts @@ -0,0 +1,49 @@ +import gql from 'graphql-tag'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { QueryFields } from '@/object-record/query-keys/types/QueryFields'; +import { capitalize } from '~/utils/string/capitalize'; + +export const generateFindManyRecordsQuery = ({ + objectMetadataItem, + objectMetadataItems, + depth, + queryFields, + computeReferences, +}: { + objectMetadataItem: ObjectMetadataItem; + objectMetadataItems: ObjectMetadataItem[]; + queryFields?: QueryFields; + depth?: number; + computeReferences?: boolean; +}) => gql` +query FindMany${capitalize( + objectMetadataItem.namePlural, +)}($filter: ${capitalize( + objectMetadataItem.nameSingular, +)}FilterInput, $orderBy: ${capitalize( + objectMetadataItem.nameSingular, +)}OrderByInput, $lastCursor: String, $limit: Float) { + ${ + objectMetadataItem.namePlural + }(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){ + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems, + objectMetadataItem, + depth, + queryFields, + computeReferences, + })} + cursor + } + pageInfo { + hasNextPage + startCursor + endCursor + } + totalCount + } +} +`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getChildRelationArray.ts b/packages/twenty-front/src/modules/object-record/utils/getChildRelationArray.ts index 74705662c884..f7a93507c687 100644 --- a/packages/twenty-front/src/modules/object-record/utils/getChildRelationArray.ts +++ b/packages/twenty-front/src/modules/object-record/utils/getChildRelationArray.ts @@ -1,15 +1,12 @@ import { ObjectRecordEdge } from '@/object-record/types/ObjectRecordEdge'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const getChildRelationArray = ({ childRelation, }: { childRelation: any; }) => { - if ( - isNonNullable(childRelation.edges) && - Array.isArray(childRelation.edges) - ) { + if (isDefined(childRelation.edges) && Array.isArray(childRelation.edges)) { return childRelation.edges.map((edge: ObjectRecordEdge) => edge.node); } else { return childRelation; diff --git a/packages/twenty-front/src/modules/object-record/utils/getCreateManyRecordsMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getCreateManyRecordsMutationResponseField.ts new file mode 100644 index 000000000000..7bd7f58a690d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getCreateManyRecordsMutationResponseField.ts @@ -0,0 +1,5 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getCreateManyRecordsMutationResponseField = ( + objectNamePlural: string, +) => `create${capitalize(objectNamePlural)}`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getCreateOneRecordMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getCreateOneRecordMutationResponseField.ts new file mode 100644 index 000000000000..9c46ccdfbb3c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getCreateOneRecordMutationResponseField.ts @@ -0,0 +1,5 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getCreateOneRecordMutationResponseField = ( + objectNameSingular: string, +) => `create${capitalize(objectNameSingular)}`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getDeleteManyRecordsMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getDeleteManyRecordsMutationResponseField.ts new file mode 100644 index 000000000000..e95201af7bd7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getDeleteManyRecordsMutationResponseField.ts @@ -0,0 +1,5 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getDeleteManyRecordsMutationResponseField = ( + objectNamePlural: string, +) => `delete${capitalize(objectNamePlural)}`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getDeleteOneRecordMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getDeleteOneRecordMutationResponseField.ts new file mode 100644 index 000000000000..fef546c878a3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getDeleteOneRecordMutationResponseField.ts @@ -0,0 +1,5 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getDeleteOneRecordMutationResponseField = ( + objectNameSingular: string, +) => `delete${capitalize(objectNameSingular)}`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getFindDuplicateRecordsQueryResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getFindDuplicateRecordsQueryResponseField.ts new file mode 100644 index 000000000000..a3df4ecd3b05 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getFindDuplicateRecordsQueryResponseField.ts @@ -0,0 +1,3 @@ +export const getFindDuplicateRecordsQueryResponseField = ( + objectNameSingular: string, +) => `${objectNameSingular}Duplicates`; diff --git a/packages/twenty-front/src/modules/object-record/utils/getUpdateOneRecordMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getUpdateOneRecordMutationResponseField.ts new file mode 100644 index 000000000000..29b16ad24436 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/getUpdateOneRecordMutationResponseField.ts @@ -0,0 +1,5 @@ +import { capitalize } from '~/utils/string/capitalize'; + +export const getUpdateOneRecordMutationResponseField = ( + objectNameSingular: string, +) => `update${capitalize(objectNameSingular)}`; diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts new file mode 100644 index 000000000000..c06278e9ad19 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/isFieldCellSupported.ts @@ -0,0 +1,37 @@ +import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isObjectMetadataAvailableForRelation } from '@/object-metadata/utils/isObjectMetadataAvailableForRelation'; +import { + FieldMetadataType, + RelationMetadataType, +} from '~/generated-metadata/graphql'; + +export const isFieldCellSupported = (fieldMetadataItem: FieldMetadataItem) => { + if ( + [FieldMetadataType.Uuid, FieldMetadataType.Position].includes( + fieldMetadataItem.type, + ) + ) { + return false; + } + + if (fieldMetadataItem.type === FieldMetadataType.Relation) { + const relationMetadata = + fieldMetadataItem.fromRelationMetadata ?? + fieldMetadataItem.toRelationMetadata; + const relationObjectMetadataItem = + fieldMetadataItem.fromRelationMetadata?.toObjectMetadata ?? + fieldMetadataItem.toRelationMetadata?.fromObjectMetadata; + + if ( + !relationMetadata || + // TODO: Many to many relations are not supported yet. + relationMetadata.relationType === RelationMetadataType.ManyToMany || + !relationObjectMetadataItem || + !isObjectMetadataAvailableForRelation(relationObjectMetadataItem) + ) { + return false; + } + } + + return !fieldMetadataItem.isSystem && !!fieldMetadataItem.isActive; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts b/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts deleted file mode 100644 index dc992cc06de2..000000000000 --- a/packages/twenty-front/src/modules/object-record/utils/isFieldMetadataItemAvailable.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { RelationMetadataType } from '~/generated-metadata/graphql'; - -export const isFieldMetadataItemAvailable = ( - fieldMetadataItem: FieldMetadataItem, -) => - fieldMetadataItem.type !== 'UUID' && - // TODO: Many to many relations are not supported yet. - !( - fieldMetadataItem.type === 'RELATION' && - ( - fieldMetadataItem.fromRelationMetadata ?? - fieldMetadataItem.toRelationMetadata - )?.relationType === RelationMetadataType.ManyToMany - ) && - !fieldMetadataItem.isSystem && - !!fieldMetadataItem.isActive; diff --git a/packages/twenty-front/src/modules/object-record/utils/makeAndFilterVariables.ts b/packages/twenty-front/src/modules/object-record/utils/makeAndFilterVariables.ts index 1b97474befcc..2f5e36d9374f 100644 --- a/packages/twenty-front/src/modules/object-record/utils/makeAndFilterVariables.ts +++ b/packages/twenty-front/src/modules/object-record/utils/makeAndFilterVariables.ts @@ -1,10 +1,10 @@ import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const makeAndFilterVariables = ( filters: (ObjectRecordQueryFilter | undefined)[], ): ObjectRecordQueryFilter | undefined => { - const definedFilters = filters.filter(isNonNullable); + const definedFilters = filters.filter(isDefined); if (!definedFilters.length) return undefined; diff --git a/packages/twenty-front/src/modules/object-record/utils/makeOrFilterVariables.ts b/packages/twenty-front/src/modules/object-record/utils/makeOrFilterVariables.ts index 2441ffee1f3f..01cceb25c9d0 100644 --- a/packages/twenty-front/src/modules/object-record/utils/makeOrFilterVariables.ts +++ b/packages/twenty-front/src/modules/object-record/utils/makeOrFilterVariables.ts @@ -1,10 +1,10 @@ import { ObjectRecordQueryFilter } from '@/object-record/record-filter/types/ObjectRecordQueryFilter'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const makeOrFilterVariables = ( filters: (ObjectRecordQueryFilter | undefined)[], ): ObjectRecordQueryFilter | undefined => { - const definedFilters = filters.filter(isNonNullable); + const definedFilters = filters.filter(isDefined); if (!definedFilters.length) return undefined; diff --git a/packages/twenty-front/src/modules/object-record/utils/mapPaginatedRecordsToRecords.ts b/packages/twenty-front/src/modules/object-record/utils/mapPaginatedRecordsToRecords.ts deleted file mode 100644 index 48cb0dd91318..000000000000 --- a/packages/twenty-front/src/modules/object-record/utils/mapPaginatedRecordsToRecords.ts +++ /dev/null @@ -1,24 +0,0 @@ -export const mapPaginatedRecordsToRecords = < - RecordType extends { id: string } & Record, - RecordTypeQuery extends { - [objectNamePlural: string]: { - edges: RecordEdge[]; - }; - }, - RecordEdge extends { - node: RecordType; - }, ->({ - pagedRecords, - objectNamePlural, -}: { - pagedRecords: RecordTypeQuery | undefined; - objectNamePlural: string; -}) => { - const formattedRecords: RecordType[] = - pagedRecords?.[objectNamePlural]?.edges?.map((recordEdge: RecordEdge) => ({ - ...recordEdge.node, - })) ?? []; - - return formattedRecords; -}; diff --git a/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts b/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts new file mode 100644 index 000000000000..4fd45e2fa8d7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts @@ -0,0 +1,35 @@ +import { isUndefined } from '@sniptt/guards'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; +import { isDefined } from '~/utils/isDefined'; + +export const prefillRecord = ({ + objectMetadataItem, + input, + depth = 1, +}: { + objectMetadataItem: ObjectMetadataItem; + input: Record; + depth?: number; +}) => { + return Object.fromEntries( + objectMetadataItem.fields + .filter( + (fieldMetadataItem) => + depth > 0 || fieldMetadataItem.type !== 'RELATION', + ) + .map((fieldMetadataItem) => { + const inputValue = input[fieldMetadataItem.name]; + + return [ + fieldMetadataItem.name, + isUndefined(inputValue) + ? generateEmptyFieldValue(fieldMetadataItem) + : inputValue, + ]; + }) + .filter(isDefined), + ) as T; +}; diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts index f8e572d976f9..9239f9f16905 100644 --- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts @@ -3,16 +3,17 @@ import { isString } from '@sniptt/guards'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { isFieldRelationValue } from '@/object-record/record-field/types/guards/isFieldRelationValue'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { sanitizeLink } from '@/object-record/utils/sanitizeLinkRecordInput'; import { FieldMetadataType } from '~/generated/graphql'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; export const sanitizeRecordInput = ({ objectMetadataItem, recordInput, }: { objectMetadataItem: ObjectMetadataItem; - recordInput: Record; + recordInput: Partial; }) => { const filteredResultRecord = Object.fromEntries( Object.entries(recordInput) @@ -23,6 +24,10 @@ export const sanitizeRecordInput = ({ if (!fieldMetadataItem) return undefined; + if (!fieldMetadataItem.isNullable && fieldValue == null) { + return undefined; + } + if ( fieldMetadataItem.type === FieldMetadataType.Relation && isFieldRelationValue(fieldValue) @@ -39,7 +44,7 @@ export const sanitizeRecordInput = ({ return [fieldName, fieldValue]; }) - .filter(isNonNullable), + .filter(isDefined), ); if ( objectMetadataItem.nameSingular !== CoreObjectNameSingular.Company || diff --git a/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts b/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts index bd9c1a66e0dd..10b0b3269476 100644 --- a/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts +++ b/packages/twenty-front/src/modules/object-record/utils/sortObjectRecordByDateField.ts @@ -2,7 +2,7 @@ import { DateTime } from 'luxon'; import { OrderBy } from '@/object-metadata/types/OrderBy'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; const SORT_BEFORE = -1; const SORT_AFTER = 1; @@ -14,11 +14,11 @@ export const sortObjectRecordByDateField = const aDate = a[dateField]; const bDate = b[dateField]; - if (!isNonNullable(aDate) && !isNonNullable(bDate)) { + if (!isDefined(aDate) && !isDefined(bDate)) { return SORT_EQUAL; } - if (!isNonNullable(aDate)) { + if (!isDefined(aDate)) { if (sortDirection === 'AscNullsFirst') { return SORT_BEFORE; } else if (sortDirection === 'DescNullsFirst') { @@ -32,7 +32,7 @@ export const sortObjectRecordByDateField = throw new Error(`Invalid sortDirection: ${sortDirection}`); } - if (!isNonNullable(bDate)) { + if (!isDefined(bDate)) { if (sortDirection === 'AscNullsFirst') { return SORT_AFTER; } else if (sortDirection === 'DescNullsFirst') { diff --git a/packages/twenty-front/src/modules/pipeline/components/PipelineAddButton.tsx b/packages/twenty-front/src/modules/pipeline/components/PipelineAddButton.tsx deleted file mode 100644 index 867474795804..000000000000 --- a/packages/twenty-front/src/modules/pipeline/components/PipelineAddButton.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { OpportunityPicker } from '@/companies/components/OpportunityPicker'; -import { useCreateOpportunity } from '@/object-record/record-board-deprecated/hooks/internal/useCreateOpportunity'; -import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; -import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; -import { PageHotkeyScope } from '@/types/PageHotkeyScope'; -import { IconPlus } from '@/ui/display/icon/index'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { IconButton } from '@/ui/input/button/components/IconButton'; -import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { logError } from '~/utils/logError'; - -export const PipelineAddButton = () => { - const { enqueueSnackBar } = useSnackBar(); - - const { closeDropdown, toggleDropdown } = useDropdown( - 'add-pipeline-progress', - ); - - const createOpportunity = useCreateOpportunity(); - - const handleCompanySelected = ( - selectedCompany: EntityForSelect | null, - selectedPipelineStepId: string | null, - ) => { - if (!selectedCompany?.id) { - enqueueSnackBar( - 'There was a problem with the company selection, please retry.', - { variant: 'error' }, - ); - - logError('There was a problem with the company selection, please retry.'); - return; - } - - if (!selectedPipelineStepId) { - enqueueSnackBar( - 'There was a problem with the pipeline stage selection, please retry.', - { variant: 'error' }, - ); - - logError('There was a problem with the pipeline step selection.'); - return; - } - closeDropdown(); - createOpportunity(selectedCompany.id, selectedPipelineStepId); - }; - - return ( - - } - dropdownComponents={ - - } - hotkey={{ - key: 'c', - scope: PageHotkeyScope.OpportunitiesPage, - }} - dropdownHotkeyScope={{ - scope: RelationPickerHotkeyScope.RelationPicker, - }} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts b/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts deleted file mode 100644 index 2d7c5a4341f1..000000000000 --- a/packages/twenty-front/src/modules/pipeline/hooks/__mocks__/usePipelineSteps.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { gql } from '@apollo/client'; - -export const query = gql` - mutation CreateOnePipelineStep($input: PipelineStepCreateInput!) { - createPipelineStep(data: $input) { - id - name - id - createdAt - opportunities { - edges { - node { - __typename - id - } - } - } - position - color - updatedAt - } - } -`; - -export const deleteQuery = gql` - mutation DeleteOnePipelineStep($idToDelete: ID!) { - deletePipelineStep(id: $idToDelete) { - id - } - } -`; - -export const mockId = '8f3b2121-f194-4ba4-9fbf-2d5a37126806'; -export const currentPipelineId = 'f088c8c9-05d2-4276-b065-b863cc7d0b33'; - -const data = { - color: 'yellow', - id: mockId, - position: 1, - name: 'Column Title', -}; - -export const variables = { - input: data, -}; - -export const deleteVariables = { idToDelete: 'columnId' }; - -export const responseData = { - ...data, - createdAt: '', - opportunities: { - edges: [], - }, - updatedAt: '', -}; - -export const deleteResponseData = { - id: 'columnId', -}; diff --git a/packages/twenty-front/src/modules/pipeline/hooks/__tests__/usePipelineSteps.test.tsx b/packages/twenty-front/src/modules/pipeline/hooks/__tests__/usePipelineSteps.test.tsx deleted file mode 100644 index b8c7c044e8f1..000000000000 --- a/packages/twenty-front/src/modules/pipeline/hooks/__tests__/usePipelineSteps.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { ReactNode } from 'react'; -import { act } from 'react-dom/test-utils'; -import { MockedProvider } from '@apollo/client/testing'; -import { renderHook } from '@testing-library/react'; -import { RecoilRoot, useSetRecoilState } from 'recoil'; - -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; -import { currentPipelineState } from '@/pipeline/states/currentPipelineState'; - -import { - currentPipelineId, - deleteQuery, - deleteResponseData, - deleteVariables, - mockId, - query, - responseData, - variables, -} from '../__mocks__/usePipelineSteps'; -import { usePipelineSteps } from '../usePipelineSteps'; - -const mocks = [ - { - request: { - query, - variables, - }, - result: jest.fn(() => ({ - data: { - createPipelineStep: responseData, - }, - })), - }, - { - request: { - query: deleteQuery, - variables: deleteVariables, - }, - result: jest.fn(() => ({ - data: { - deletePipelineStep: deleteResponseData, - }, - })), - }, -]; - -const Wrapper = ({ children }: { children: ReactNode }) => ( - - - {children} - - -); - -jest.mock('uuid', () => ({ - v4: jest.fn(() => mockId), -})); - -describe('usePipelineSteps', () => { - it('should handlePipelineStepAdd successfully', async () => { - const { result } = renderHook( - () => { - const setCurrentPipeline = useSetRecoilState(currentPipelineState); - setCurrentPipeline({ id: currentPipelineId }); - return usePipelineSteps(); - }, - { - wrapper: Wrapper, - }, - ); - - const boardColumn: BoardColumnDefinition = { - id: mockId, - title: 'Column Title', - colorCode: 'yellow', - position: 1, - }; - - await act(async () => { - const res = await result.current.handlePipelineStepAdd(boardColumn); - expect(res).toEqual(responseData); - }); - }); - - it('should handlePipelineStepDelete successfully', async () => { - const { result } = renderHook( - () => { - const setCurrentPipeline = useSetRecoilState(currentPipelineState); - setCurrentPipeline({ id: currentPipelineId }); - return usePipelineSteps(); - }, - { - wrapper: Wrapper, - }, - ); - - const boardColumnId = 'columnId'; - - await act(async () => { - const res = await result.current.handlePipelineStepDelete(boardColumnId); - expect(res).toEqual(deleteResponseData); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts b/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts deleted file mode 100644 index f68335f693b5..000000000000 --- a/packages/twenty-front/src/modules/pipeline/hooks/usePipelineSteps.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { useRecoilCallback } from 'recoil'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord'; -import { BoardColumnDefinition } from '@/object-record/record-board-deprecated/types/BoardColumnDefinition'; -import { currentPipelineState } from '@/pipeline/states/currentPipelineState'; -import { PipelineStep } from '@/pipeline/types/PipelineStep'; - -export const usePipelineSteps = () => { - const { createOneRecord: createOnePipelineStep } = - useCreateOneRecord({ - objectNameSingular: CoreObjectNameSingular.PipelineStep, - }); - - const { deleteOneRecord: deleteOnePipelineStep } = useDeleteOneRecord({ - objectNameSingular: CoreObjectNameSingular.PipelineStep, - }); - - const handlePipelineStepAdd = useRecoilCallback( - ({ snapshot }) => - async (boardColumn: BoardColumnDefinition) => { - const currentPipeline = await snapshot.getPromise(currentPipelineState); - if (!currentPipeline?.id) return; - - return createOnePipelineStep?.({ - color: boardColumn.colorCode ?? 'gray', - id: boardColumn.id, - position: boardColumn.position, - name: boardColumn.title, - }); - }, - [createOnePipelineStep], - ); - - const handlePipelineStepDelete = useRecoilCallback( - ({ snapshot }) => - async (boardColumnId: string) => { - const currentPipeline = await snapshot.getPromise(currentPipelineState); - if (!currentPipeline?.id) return; - - return deleteOnePipelineStep?.(boardColumnId); - }, - [deleteOnePipelineStep], - ); - - return { handlePipelineStepAdd, handlePipelineStepDelete }; -}; diff --git a/packages/twenty-front/src/modules/pipeline/states/currentPipelineState.ts b/packages/twenty-front/src/modules/pipeline/states/currentPipelineState.ts deleted file mode 100644 index 2de1a5e5fba3..000000000000 --- a/packages/twenty-front/src/modules/pipeline/states/currentPipelineState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { atom } from 'recoil'; -import { undefined } from 'zod'; - -export const currentPipelineState = atom({ - key: 'currentPipelineState', - default: undefined, -}); diff --git a/packages/twenty-front/src/modules/pipeline/states/currentPipelineStepsState.ts b/packages/twenty-front/src/modules/pipeline/states/currentPipelineStepsState.ts deleted file mode 100644 index fce0bc74598c..000000000000 --- a/packages/twenty-front/src/modules/pipeline/states/currentPipelineStepsState.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { atom } from 'recoil'; - -import { PipelineStep } from '@/pipeline/types/PipelineStep'; - -export const currentPipelineStepsState = atom({ - key: 'currentPipelineStepsState', - default: [], -}); diff --git a/packages/twenty-front/src/modules/pipeline/types/Opportunity.ts b/packages/twenty-front/src/modules/pipeline/types/Opportunity.ts deleted file mode 100644 index 3525841f89b3..000000000000 --- a/packages/twenty-front/src/modules/pipeline/types/Opportunity.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Person } from '@/people/types/Person'; -import { PipelineStep } from '@/pipeline/types/PipelineStep'; - -export type Opportunity = { - [key: string]: any; - id: string; - amount: { - amountMicros: number; - currencyCode: string; - }; - closeDate: Date; - probability: number; - pipelineStepId: string; - pipelineStep: PipelineStep; - pointOfContactId: string; - pointOfContact: Pick; -}; diff --git a/packages/twenty-front/src/modules/pipeline/types/PipelineStep.ts b/packages/twenty-front/src/modules/pipeline/types/PipelineStep.ts deleted file mode 100644 index 76773be081d1..000000000000 --- a/packages/twenty-front/src/modules/pipeline/types/PipelineStep.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type PipelineStep = { - id: string; - name: string; - color: string; - position: number; - createdAt: string; - updatedAt: string; -}; diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx new file mode 100644 index 000000000000..837d2498e576 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchDataProvider.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +import { PrefetchRunQueriesEffect } from '@/prefetch/components/PrefetchRunQueriesEffect'; + +export const PrefetchDataProvider = ({ children }: React.PropsWithChildren) => { + return ( + <> + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx new file mode 100644 index 000000000000..e1ed69d59707 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/components/PrefetchRunQueriesEffect.tsx @@ -0,0 +1,46 @@ +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { currentUserState } from '@/auth/states/currentUserState'; +import { Favorite } from '@/favorites/types/Favorite'; +import { useFindManyRecordsForMultipleMetadataItems } from '@/object-record/multiple-objects/hooks/useFindManyRecordsForMultipleMetadataItems'; +import { usePrefetchRunQuery } from '@/prefetch/hooks/internal/usePrefetchRunQuery'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { View } from '@/views/types/View'; +import { isDefined } from '~/utils/isDefined'; + +export const PrefetchRunQueriesEffect = () => { + const currentUser = useRecoilValue(currentUserState); + + const { + objectMetadataItem: objectMetadataItemView, + upsertRecordsInCache: upsertViewsInCache, + } = usePrefetchRunQuery({ + prefetchKey: PrefetchKey.AllViews, + }); + + const { + objectMetadataItem: objectMetadataItemFavorite, + upsertRecordsInCache: upsertFavoritesInCache, + } = usePrefetchRunQuery({ + prefetchKey: PrefetchKey.AllFavorites, + }); + + const { result } = useFindManyRecordsForMultipleMetadataItems({ + objectMetadataItems: [objectMetadataItemView, objectMetadataItemFavorite], + skip: !currentUser, + depth: 1, + }); + + useEffect(() => { + if (isDefined(result.views)) { + upsertViewsInCache(result.views as View[]); + } + + if (isDefined(result.favorites)) { + upsertFavoritesInCache(result.favorites as Favorite[]); + } + }, [result, upsertViewsInCache, upsertFavoritesInCache]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/prefetch/constants/PrefetchConfig.ts b/packages/twenty-front/src/modules/prefetch/constants/PrefetchConfig.ts new file mode 100644 index 000000000000..a79631158314 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/constants/PrefetchConfig.ts @@ -0,0 +1,9 @@ +import { QueryKey } from '@/object-record/query-keys/types/QueryKey'; +import { ALL_FAVORITES_QUERY_KEY } from '@/prefetch/query-keys/AllFavoritesQueryKey'; +import { ALL_VIEWS_QUERY_KEY } from '@/prefetch/query-keys/AllViewsQueryKey'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; + +export const PREFETCH_CONFIG: Record = { + ALL_VIEWS: ALL_VIEWS_QUERY_KEY, + ALL_FAVORITES: ALL_FAVORITES_QUERY_KEY, +}; diff --git a/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts b/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts new file mode 100644 index 000000000000..b010e65db750 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/hooks/internal/usePrefetchRunQuery.ts @@ -0,0 +1,44 @@ +import { useSetRecoilState } from 'recoil'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useUpsertFindManyRecordsQueryInCache } from '@/object-record/cache/hooks/useUpsertFindManyRecordsQueryInCache'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig'; +import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; + +export type UsePrefetchRunQuery = { + prefetchKey: PrefetchKey; +}; + +export const usePrefetchRunQuery = ({ + prefetchKey, +}: UsePrefetchRunQuery) => { + const setPrefetchDataIsLoadedLoaded = useSetRecoilState( + prefetchIsLoadedFamilyState(prefetchKey), + ); + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: PREFETCH_CONFIG[prefetchKey].objectNameSingular, + }); + + const { upsertFindManyRecordsQueryInCache } = + useUpsertFindManyRecordsQueryInCache({ + objectMetadataItem: objectMetadataItem, + }); + + const upsertRecordsInCache = (records: T[]) => { + upsertFindManyRecordsQueryInCache({ + queryVariables: PREFETCH_CONFIG[prefetchKey].variables, + depth: PREFETCH_CONFIG[prefetchKey].depth, + objectRecordsToOverwrite: records, + computeReferences: false, + }); + setPrefetchDataIsLoadedLoaded(true); + }; + + return { + objectMetadataItem, + setPrefetchDataIsLoadedLoaded, + upsertRecordsInCache, + }; +}; diff --git a/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts b/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts new file mode 100644 index 000000000000..3072733c85f2 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/hooks/usePrefetchedData.ts @@ -0,0 +1,26 @@ +import { useRecoilValue } from 'recoil'; + +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { PREFETCH_CONFIG } from '@/prefetch/constants/PrefetchConfig'; +import { prefetchIsLoadedFamilyState } from '@/prefetch/states/prefetchIsLoadedFamilyState'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; + +export const usePrefetchedData = ( + prefetchKey: PrefetchKey, +) => { + const isDataPrefetched = useRecoilValue( + prefetchIsLoadedFamilyState(prefetchKey), + ); + const prefetchQueryKey = PREFETCH_CONFIG[prefetchKey]; + + const { records } = useFindManyRecords({ + skip: !isDataPrefetched, + ...prefetchQueryKey, + }); + + return { + isDataPrefetched, + records, + }; +}; diff --git a/packages/twenty-front/src/modules/prefetch/query-keys/AllFavoritesQueryKey.ts b/packages/twenty-front/src/modules/prefetch/query-keys/AllFavoritesQueryKey.ts new file mode 100644 index 000000000000..a5e442715881 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/query-keys/AllFavoritesQueryKey.ts @@ -0,0 +1,8 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { QueryKey } from '@/object-record/query-keys/types/QueryKey'; + +export const ALL_FAVORITES_QUERY_KEY: QueryKey = { + objectNameSingular: CoreObjectNameSingular.Favorite, + variables: {}, + depth: 1, +}; diff --git a/packages/twenty-front/src/modules/prefetch/query-keys/AllViewsQueryKey.ts b/packages/twenty-front/src/modules/prefetch/query-keys/AllViewsQueryKey.ts new file mode 100644 index 000000000000..df1c78163048 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/query-keys/AllViewsQueryKey.ts @@ -0,0 +1,8 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { QueryKey } from '@/object-record/query-keys/types/QueryKey'; + +export const ALL_VIEWS_QUERY_KEY: QueryKey = { + objectNameSingular: CoreObjectNameSingular.View, + variables: {}, + depth: 1, +}; diff --git a/packages/twenty-front/src/modules/prefetch/states/prefetchIsLoadedFamilyState.ts b/packages/twenty-front/src/modules/prefetch/states/prefetchIsLoadedFamilyState.ts new file mode 100644 index 000000000000..38069ee05801 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/states/prefetchIsLoadedFamilyState.ts @@ -0,0 +1,10 @@ +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { createFamilyState } from '@/ui/utilities/state/utils/createFamilyState'; + +export const prefetchIsLoadedFamilyState = createFamilyState< + boolean, + PrefetchKey +>({ + key: 'prefetchIsLoadedFamilyState', + defaultValue: false, +}); diff --git a/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts b/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts new file mode 100644 index 000000000000..f7ebf434e916 --- /dev/null +++ b/packages/twenty-front/src/modules/prefetch/types/PrefetchKey.ts @@ -0,0 +1,4 @@ +export enum PrefetchKey { + AllViews = 'ALL_VIEWS', + AllFavorites = 'ALL_FAVORITES', +} diff --git a/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts b/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts index 39201e66a8fc..cb1daf98147c 100644 --- a/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/__mocks__/useFilteredSearchEntityQuery.ts @@ -24,7 +24,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { @@ -49,7 +49,7 @@ export const query = gql` pointOfContactId updatedAt companyId - pipelineStepId + stage probability closeDate amount { diff --git a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts index 316c092cb13a..d7beaefce4fb 100644 --- a/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts +++ b/packages/twenty-front/src/modules/search/hooks/useFilteredSearchEntityQuery.ts @@ -1,6 +1,6 @@ import { isNonEmptyString } from '@sniptt/guards'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useMapToObjectRecordIdentifier } from '@/object-metadata/hooks/useMapToObjectRecordIdentifier'; import { OrderBy } from '@/object-metadata/types/OrderBy'; import { DEFAULT_SEARCH_REQUEST_LIMIT } from '@/object-record/constants/DefaultSearchRequestLimit'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; @@ -9,7 +9,7 @@ import { EntityForSelect } from '@/object-record/relation-picker/types/EntityFor import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; import { makeOrFilterVariables } from '@/object-record/utils/makeOrFilterVariables'; -import { isNonNullable } from '~/utils/isNonNullable'; +import { isDefined } from '~/utils/isDefined'; type SearchFilter = { fieldNames: string[]; filter: string | number }; @@ -33,9 +33,10 @@ export const useFilteredSearchEntityQuery = ({ excludeEntityIds?: string[]; objectNameSingular: string; }): EntitiesForMultipleEntitySelect => { - const { mapToObjectRecordIdentifier } = useObjectMetadataItem({ + const { mapToObjectRecordIdentifier } = useMapToObjectRecordIdentifier({ objectNameSingular, }); + const mappingFunction = (record: ObjectRecord) => ({ ...mapToObjectRecordIdentifier(record), record, @@ -59,7 +60,7 @@ export const useFilteredSearchEntityQuery = ({ fieldNames.map((fieldName) => { const [parentFieldName, subFieldName] = fieldName.split('.'); - if (subFieldName) { + if (isNonEmptyString(subFieldName)) { // Composite field return { [parentFieldName]: { @@ -102,15 +103,11 @@ export const useFilteredSearchEntityQuery = ({ }); return { - selectedEntities: selectedRecords - .map(mappingFunction) - .filter(isNonNullable), + selectedEntities: selectedRecords.map(mappingFunction).filter(isDefined), filteredSelectedEntities: filteredSelectedRecords .map(mappingFunction) - .filter(isNonNullable), - entitiesToSelect: recordsToSelect - .map(mappingFunction) - .filter(isNonNullable), + .filter(isDefined), + entitiesToSelect: recordsToSelect.map(mappingFunction).filter(isDefined), loading: recordsToSelectLoading || filteredSelectedRecordsLoading || diff --git a/packages/twenty-front/src/pages/settings/accounts/SettingsAccountLoader.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountLoader.tsx similarity index 100% rename from packages/twenty-front/src/pages/settings/accounts/SettingsAccountLoader.tsx rename to packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountLoader.tsx diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarAccountsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarAccountsListCard.tsx deleted file mode 100644 index 5425976316ec..000000000000 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarAccountsListCard.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useNavigate } from 'react-router-dom'; -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; - -import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; -import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { SettingsAccountsListCard } from '@/settings/accounts/components/SettingsAccountsListCard'; -import { SettingsAccountsSynchronizationStatus } from '@/settings/accounts/components/SettingsAccountsSynchronizationStatus'; -import { IconChevronRight } from '@/ui/display/icon'; -import { IconGoogleCalendar } from '@/ui/display/icon/components/IconGoogleCalendar'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; -import { mockedConnectedAccounts } from '~/testing/mock-data/accounts'; - -const StyledRowRightContainer = styled.div` - align-items: center; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; -`; - -export const SettingsAccountsCalendarAccountsListCard = () => { - const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const navigate = useNavigate(); - - const { records: _accounts, loading } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.ConnectedAccount, - filter: { - accountOwnerId: { - eq: currentWorkspaceMember?.id, - }, - }, - }); - - return ( - - navigate(`/settings/accounts/calendars/${account.id}`) - } - RowIcon={IconGoogleCalendar} - RowRightComponent={({ account: _account }) => ( - - - - - )} - /> - ); -}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx new file mode 100644 index 000000000000..3222dc89ba44 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsListCard.tsx @@ -0,0 +1,91 @@ +import { useNavigate } from 'react-router-dom'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; +import { IconChevronRight } from 'twenty-ui'; + +import { CalendarChannel } from '@/accounts/types/CalendarChannel'; +import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard'; +import { + SettingsAccountsSynchronizationStatus, + SettingsAccountsSynchronizationStatusProps, +} from '@/settings/accounts/components/SettingsAccountsSynchronizationStatus'; +import { SettingsListCard } from '@/settings/components/SettingsListCard'; +import { IconGoogleCalendar } from '@/ui/display/icon/components/IconGoogleCalendar'; +import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; + +const StyledRowRightContainer = styled.div` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const SettingsAccountsCalendarChannelsListCard = () => { + const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); + const navigate = useNavigate(); + + const { records: accounts, loading: accountsLoading } = + useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.ConnectedAccount, + filter: { + accountOwnerId: { + eq: currentWorkspaceMember?.id, + }, + }, + }); + + const { records: calendarChannels, loading: calendarChannelsLoading } = + useFindManyRecords< + CalendarChannel & { + connectedAccount: ConnectedAccount; + } + >({ + objectNameSingular: CoreObjectNameSingular.CalendarChannel, + skip: !accounts.length, + filter: { + connectedAccountId: { + in: accounts.map((account) => account.id), + }, + }, + }); + + if (!calendarChannels.length) { + return ; + } + + const calendarChannelsWithSyncStatus: (CalendarChannel & { + connectedAccount: ConnectedAccount; + } & SettingsAccountsSynchronizationStatusProps)[] = calendarChannels.map( + (calendarChannel) => ({ + ...calendarChannel, + syncStatus: calendarChannel.connectedAccount?.authFailedAt + ? 'failed' + : calendarChannel.isSyncEnabled + ? 'synced' + : 'notSynced', + }), + ); + + return ( + calendarChannel.handle} + isLoading={accountsLoading || calendarChannelsLoading} + onRowClick={(calendarChannel) => + navigate(`/settings/accounts/calendars/${calendarChannel.id}`) + } + RowIcon={IconGoogleCalendar} + RowRightComponent={({ item: calendarChannel }) => ( + + + + + )} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx new file mode 100644 index 000000000000..278af533e3ab --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarDateFormatSelect.tsx @@ -0,0 +1,34 @@ +import { formatInTimeZone } from 'date-fns-tz'; + +import { DateFormat } from '@/settings/accounts/constants/DateFormat'; +import { Select } from '@/ui/input/components/Select'; + +type SettingsAccountsCalendarDateFormatSelectProps = { + value: DateFormat; + onChange: (nextValue: DateFormat) => void; + timeZone: string; +}; + +export const SettingsAccountsCalendarDateFormatSelect = ({ + onChange, + timeZone, + value, +}: SettingsAccountsCalendarDateFormatSelectProps) => ( + + ); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx new file mode 100644 index 000000000000..cab95257dd15 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarTimeFormatSelect.tsx @@ -0,0 +1,42 @@ +import { formatInTimeZone } from 'date-fns-tz'; + +import { TimeFormat } from '@/settings/accounts/constants/TimeFormat'; +import { Select } from '@/ui/input/components/Select'; + +type SettingsAccountsCalendarTimeFormatSelectProps = { + value: TimeFormat; + onChange: (nextValue: TimeFormat) => void; + timeZone: string; +}; + +export const SettingsAccountsCalendarTimeFormatSelect = ({ + onChange, + timeZone, + value, +}: SettingsAccountsCalendarTimeFormatSelectProps) => ( + onChange?.(value)} + options={[ + { + value: true, + label: 'True', + Icon: IconCheck, + }, + { + value: false, + label: 'False', + Icon: IconX, + }, + ]} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx index f0afa115e9eb..725b9a51ed3d 100644 --- a/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/components/SettingsObjectFieldCurrencyForm.tsx @@ -23,7 +23,7 @@ export const SettingsObjectFieldCurrencyForm = ({