Compare commits
10 Commits
@nhost/das
...
storage@0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438355bff0 | ||
|
|
62b97838fe | ||
|
|
d287191f7a | ||
|
|
b8d2127b06 | ||
|
|
28cec232c8 | ||
|
|
fe853da133 | ||
|
|
c4445135bf | ||
|
|
db7366dfc7 | ||
|
|
31c503e458 | ||
|
|
187d35412e |
41
.github/actions/validate-pr-title/action.yaml
vendored
41
.github/actions/validate-pr-title/action.yaml
vendored
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Validate PR Title"
|
|
||||||
description: "Validates that PR title follows the required format: TYPE(PKG): SUMMARY"
|
|
||||||
inputs:
|
|
||||||
pr_title:
|
|
||||||
description: "The PR title to validate"
|
|
||||||
required: true
|
|
||||||
runs:
|
|
||||||
using: "composite"
|
|
||||||
steps:
|
|
||||||
- name: "Validate PR title format"
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
PR_TITLE="${{ inputs.pr_title }}"
|
|
||||||
|
|
||||||
echo "Validating PR title: $PR_TITLE"
|
|
||||||
|
|
||||||
# Define valid types and packages
|
|
||||||
VALID_TYPES="feat|fix|chore"
|
|
||||||
VALID_PKGS="ci|cli|codegen|dashboard|deps|docs|examples|mintlify-openapi|nhost-js|nixops|storage"
|
|
||||||
|
|
||||||
# Check if title matches the pattern TYPE(PKG): SUMMARY
|
|
||||||
if [[ ! "$PR_TITLE" =~ ^(${VALID_TYPES})\((${VALID_PKGS})\):\ .+ ]]; then
|
|
||||||
echo "❌ PR title does not follow the required format!"
|
|
||||||
echo ""
|
|
||||||
echo "Expected format: TYPE(PKG): SUMMARY"
|
|
||||||
echo ""
|
|
||||||
echo "Valid TYPEs:"
|
|
||||||
echo " - feat: mark this pull request as a feature"
|
|
||||||
echo " - fix: mark this pull request as a bug fix"
|
|
||||||
echo " - chore: mark this pull request as a maintenance item"
|
|
||||||
echo ""
|
|
||||||
echo "Valid PKGs:"
|
|
||||||
echo " - ci, cli, codegen, dashboard, deps, docs, examples,"
|
|
||||||
echo " - mintlify-openapi, nhost-js, nixops, storage"
|
|
||||||
echo ""
|
|
||||||
echo "Example: feat(cli): add new command for database migrations"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "✅ PR title is valid!"
|
|
||||||
6
.github/workflows/ci_release.yaml
vendored
6
.github/workflows/ci_release.yaml
vendored
@@ -36,7 +36,7 @@ jobs:
|
|||||||
cli:
|
cli:
|
||||||
needs: extract-project
|
needs: extract-project
|
||||||
if: needs.extract-project.outputs.project == 'cli'
|
if: needs.extract-project.outputs.project == 'cli'
|
||||||
uses: ./.github/workflows/cli_wf_release.yaml
|
uses: ./.github/workflows/cli_release.yaml
|
||||||
with:
|
with:
|
||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
dashboard:
|
dashboard:
|
||||||
needs: extract-project
|
needs: extract-project
|
||||||
if: needs.extract-project.outputs.project == '@nhost/dashboard'
|
if: needs.extract-project.outputs.project == '@nhost/dashboard'
|
||||||
uses: ./.github/workflows/dashboard_wf_release.yaml
|
uses: ./.github/workflows/dashboard_release.yaml
|
||||||
with:
|
with:
|
||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||||
@@ -85,7 +85,7 @@ jobs:
|
|||||||
storage:
|
storage:
|
||||||
needs: extract-project
|
needs: extract-project
|
||||||
if: needs.extract-project.outputs.project == 'storage'
|
if: needs.extract-project.outputs.project == 'storage'
|
||||||
uses: ./.github/workflows/storage_wf_release.yaml
|
uses: ./.github/workflows/storage_release.yaml
|
||||||
with:
|
with:
|
||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||||
|
|||||||
6
.github/workflows/cli_checks.yaml
vendored
6
.github/workflows/cli_checks.yaml
vendored
@@ -27,10 +27,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -74,7 +70,7 @@ jobs:
|
|||||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||||
|
|
||||||
test_cli_build:
|
test_cli_build:
|
||||||
uses: ./.github/workflows/cli_wf_test_new_project.yaml
|
uses: ./.github/workflows/cli_test_new_project.yaml
|
||||||
needs:
|
needs:
|
||||||
- check-permissions
|
- check-permissions
|
||||||
- build_artifacts
|
- build_artifacts
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ jobs:
|
|||||||
ref: ${{ inputs.GIT_REF }}
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
comment_on_pr: false
|
comment_on_pr: false
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
4
.github/workflows/codegen_checks.yaml
vendored
4
.github/workflows/codegen_checks.yaml
vendored
@@ -25,10 +25,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
8
.github/workflows/dashboard_checks.yaml
vendored
8
.github/workflows/dashboard_checks.yaml
vendored
@@ -32,10 +32,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -63,7 +59,6 @@ jobs:
|
|||||||
VERCEL_TEAM_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
VERCEL_TEAM_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||||
VERCEL_DEPLOY_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
VERCEL_DEPLOY_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
|
|
||||||
|
|
||||||
build_artifacts:
|
build_artifacts:
|
||||||
@@ -76,7 +71,6 @@ jobs:
|
|||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||||
DOCKER: true
|
DOCKER: true
|
||||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
|
||||||
secrets:
|
secrets:
|
||||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||||
@@ -99,7 +93,7 @@ jobs:
|
|||||||
|
|
||||||
|
|
||||||
e2e_staging:
|
e2e_staging:
|
||||||
uses: ./.github/workflows/dashboard_wf_e2e_staging.yaml
|
uses: ./.github/workflows/wf_dashboard_e2e_staging.yaml
|
||||||
needs:
|
needs:
|
||||||
- check-permissions
|
- check-permissions
|
||||||
- deploy-vercel
|
- deploy-vercel
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ jobs:
|
|||||||
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
VERCEL_DEPLOY_TOKEN: ${{ secrets.VERCEL_DEPLOY_TOKEN }}
|
VERCEL_DEPLOY_TOKEN: ${{ secrets.VERCEL_DEPLOY_TOKEN }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||||
|
|
||||||
build_artifacts:
|
build_artifacts:
|
||||||
@@ -98,7 +97,6 @@ jobs:
|
|||||||
committer: GitHub <noreply@github.com>
|
committer: GitHub <noreply@github.com>
|
||||||
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
|
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
|
||||||
body: |
|
body: |
|
||||||
This PR bumps the Nhost Dashboard Docker image to version ${{ inputs.VERSION }}.
|
This PR bumps the Nhost Dashboard Docker image to version ${{ needs.version.outputs.dashboardVersion }}.
|
||||||
branch: bump-dashboard-version
|
branch: bump-dashboard-version
|
||||||
base: main
|
|
||||||
delete-branch: true
|
delete-branch: true
|
||||||
27
.github/workflows/dashboard_release_staging.yaml
vendored
27
.github/workflows/dashboard_release_staging.yaml
vendored
@@ -4,32 +4,6 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
paths:
|
|
||||||
- '.github/workflows/wf_build_artifacts.yaml'
|
|
||||||
- '.github/workflows/wf_check.yaml'
|
|
||||||
- '.github/workflows/dashboard_checks.yaml'
|
|
||||||
|
|
||||||
# common build
|
|
||||||
- 'flake.nix'
|
|
||||||
- 'flake.lock'
|
|
||||||
- 'nixops/**'
|
|
||||||
- 'build/**'
|
|
||||||
|
|
||||||
# common javascript
|
|
||||||
- ".npmrc"
|
|
||||||
- ".prettierignore"
|
|
||||||
- ".prettierrc.js"
|
|
||||||
- "audit-ci.jsonc"
|
|
||||||
- "package.json"
|
|
||||||
- "pnpm-workspace.yaml"
|
|
||||||
- "pnpm-lock.yaml"
|
|
||||||
- "turbo.json"
|
|
||||||
|
|
||||||
# dashboard
|
|
||||||
- "dashboard/**"
|
|
||||||
|
|
||||||
# nhost-js
|
|
||||||
- packages/nhost-js/**
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-vercel:
|
deploy-vercel:
|
||||||
@@ -45,5 +19,4 @@ jobs:
|
|||||||
VERCEL_TEAM_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
VERCEL_TEAM_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||||
VERCEL_DEPLOY_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
VERCEL_DEPLOY_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_STAGING }}
|
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_STAGING }}
|
||||||
|
|||||||
4
.github/workflows/docs_checks.yaml
vendored
4
.github/workflows/docs_checks.yaml
vendored
@@ -31,10 +31,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
5
.github/workflows/examples_demos_checks.yaml
vendored
5
.github/workflows/examples_demos_checks.yaml
vendored
@@ -41,10 +41,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -81,7 +77,6 @@ jobs:
|
|||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||||
DOCKER: false
|
DOCKER: false
|
||||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
|
||||||
secrets:
|
secrets:
|
||||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -81,7 +77,6 @@ jobs:
|
|||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||||
DOCKER: false
|
DOCKER: false
|
||||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
|
||||||
secrets:
|
secrets:
|
||||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||||
|
|||||||
@@ -41,10 +41,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -81,7 +77,6 @@ jobs:
|
|||||||
GIT_REF: ${{ github.sha }}
|
GIT_REF: ${{ github.sha }}
|
||||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||||
DOCKER: false
|
DOCKER: false
|
||||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
|
||||||
secrets:
|
secrets:
|
||||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||||
|
|||||||
4
.github/workflows/gen_ai_review.yaml
vendored
4
.github/workflows/gen_ai_review.yaml
vendored
@@ -21,6 +21,6 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
config.model: ${{ vars.GEN_AI_MODEL }}
|
config.model: "anthropic/claude-sonnet-4-20250514"
|
||||||
config.model_turbo: $${{ vars.GEN_AI_MODEL_TURBO }}
|
config.model_turbo: "anthropic/claude-sonnet-4-20250514"
|
||||||
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml', 'vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
|
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml', 'vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
|
||||||
|
|||||||
10
.github/workflows/gen_codeql-analysis.yml
vendored
10
.github/workflows/gen_codeql-analysis.yml
vendored
@@ -1,6 +1,8 @@
|
|||||||
name: "CodeQL"
|
name: "CodeQL"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push: {}
|
||||||
|
pull_request: {}
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '20 23 * * 3'
|
- cron: '20 23 * * 3'
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
language: [ 'javascript', 'go' ]
|
language: [ 'javascript' ]
|
||||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
|
|
||||||
@@ -26,7 +28,7 @@ jobs:
|
|||||||
|
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@v3
|
uses: github/codeql-action/init@v2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -37,7 +39,7 @@ jobs:
|
|||||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
# If this step fails, then you should remove it and run the build manually (see below)
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
- name: Autobuild
|
- name: Autobuild
|
||||||
uses: github/codeql-action/autobuild@v3
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
# ℹ️ Command-line programs to run using the OS shell.
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
# 📚 https://git.io/JvXDl
|
# 📚 https://git.io/JvXDl
|
||||||
@@ -51,4 +53,4 @@ jobs:
|
|||||||
# make release
|
# make release
|
||||||
|
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@v3
|
uses: github/codeql-action/analyze@v2
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
|
|
||||||
- uses: nixbuild/nix-quick-install-action@v34
|
- uses: nixbuild/nix-quick-install-action@v26
|
||||||
with:
|
with:
|
||||||
nix_version: 2.16.2
|
nix_version: 2.16.2
|
||||||
nix_conf: |
|
nix_conf: |
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
"
|
"
|
||||||
|
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: peter-evans/create-pull-request@v7
|
uses: peter-evans/create-pull-request@v6
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
commit-message: Update dependencies
|
commit-message: Update dependencies
|
||||||
|
|||||||
4
.github/workflows/nhost-js_checks.yaml
vendored
4
.github/workflows/nhost-js_checks.yaml
vendored
@@ -38,10 +38,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
4
.github/workflows/nixops_checks.yaml
vendored
4
.github/workflows/nixops_checks.yaml
vendored
@@ -17,10 +17,6 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
6
.github/workflows/storage_checks.yaml
vendored
6
.github/workflows/storage_checks.yaml
vendored
@@ -21,15 +21,11 @@ on:
|
|||||||
- 'vendor/**'
|
- 'vendor/**'
|
||||||
|
|
||||||
# storage
|
# storage
|
||||||
- 'services/storage/**'
|
- 'storage/**'
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || format('push-{0}', github.sha) }}
|
|
||||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check-permissions:
|
check-permissions:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
14
.github/workflows/wf_build_artifacts.yaml
vendored
14
.github/workflows/wf_build_artifacts.yaml
vendored
@@ -17,10 +17,6 @@ on:
|
|||||||
DOCKER:
|
DOCKER:
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
OS_MATRIX:
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: '["blacksmith-4vcpu-ubuntu-2404-arm", "blacksmith-2vcpu-ubuntu-2404"]'
|
|
||||||
secrets:
|
secrets:
|
||||||
AWS_ACCOUNT_ID:
|
AWS_ACCOUNT_ID:
|
||||||
required: true
|
required: true
|
||||||
@@ -41,7 +37,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: ${{ fromJSON(inputs.OS_MATRIX) }}
|
os: [blacksmith-4vcpu-ubuntu-2404-arm, blacksmith-2vcpu-ubuntu-2404]
|
||||||
fail-fast: true
|
fail-fast: true
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
@@ -53,14 +49,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ inputs.GIT_REF }}
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
|
||||||
- name: "Validate PR title"
|
|
||||||
uses: ./.github/actions/validate-pr-title
|
|
||||||
with:
|
|
||||||
pr_title: ${{ github.event.pull_request.title }}
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
|
|||||||
8
.github/workflows/wf_check.yaml
vendored
8
.github/workflows/wf_check.yaml
vendored
@@ -44,19 +44,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
ref: ${{ inputs.GIT_REF }}
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
|
||||||
- name: "Validate PR title"
|
|
||||||
uses: ./.github/actions/validate-pr-title
|
|
||||||
with:
|
|
||||||
pr_title: ${{ github.event.pull_request.title }}
|
|
||||||
if: ${{ github.event_name == 'pull_request' }}
|
|
||||||
|
|
||||||
- name: Collect Workflow Telemetry
|
- name: Collect Workflow Telemetry
|
||||||
uses: catchpoint/workflow-telemetry-action@v2
|
uses: catchpoint/workflow-telemetry-action@v2
|
||||||
with:
|
with:
|
||||||
comment_on_pr: false
|
comment_on_pr: false
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
|
|||||||
@@ -59,10 +59,6 @@ on:
|
|||||||
PLAYWRIGHT_REPORT_ENCRYPTION_KEY:
|
PLAYWRIGHT_REPORT_ENCRYPTION_KEY:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: dashboard-e2e-staging
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NEXT_PUBLIC_ENV: dev
|
NEXT_PUBLIC_ENV: dev
|
||||||
NEXT_TELEMETRY_DISABLED: 1
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
@@ -105,7 +101,7 @@ jobs:
|
|||||||
comment_on_pr: false
|
comment_on_pr: false
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
6
.github/workflows/wf_deploy_vercel.yaml
vendored
6
.github/workflows/wf_deploy_vercel.yaml
vendored
@@ -27,8 +27,6 @@ on:
|
|||||||
required: true
|
required: true
|
||||||
DISCORD_WEBHOOK:
|
DISCORD_WEBHOOK:
|
||||||
required: false
|
required: false
|
||||||
TURBO_TOKEN:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
preview-url:
|
preview-url:
|
||||||
@@ -54,7 +52,7 @@ jobs:
|
|||||||
ref: ${{ inputs.GIT_REF }}
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
@@ -71,8 +69,6 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: nhost
|
|
||||||
run: |
|
run: |
|
||||||
TARGET_OPTS="--target=${{ inputs.ENVIRONMENT }}"
|
TARGET_OPTS="--target=${{ inputs.ENVIRONMENT }}"
|
||||||
echo "Deploying to: ${{ inputs.ENVIRONMENT }}..."
|
echo "Deploying to: ${{ inputs.ENVIRONMENT }}..."
|
||||||
|
|||||||
2
.github/workflows/wf_docker_push_image.yaml
vendored
2
.github/workflows/wf_docker_push_image.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out repository"
|
- name: "Check out repository"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Check out repository"
|
- name: "Check out repository"
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
submodules: true
|
submodules: true
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
|
|||||||
2
.github/workflows/wf_release_npm.yaml
vendored
2
.github/workflows/wf_release_npm.yaml
vendored
@@ -42,7 +42,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Configure aws
|
- name: Configure aws
|
||||||
uses: aws-actions/configure-aws-credentials@v5
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
with:
|
with:
|
||||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
aws-region: eu-central-1
|
aws-region: eu-central-1
|
||||||
|
|||||||
20
cli/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
20
cli/.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
## Description
|
||||||
|
<!--
|
||||||
|
Use one of the following title prefix to categorize the pull request:
|
||||||
|
feat: mark this pull request as a feature
|
||||||
|
fix: mark this pull request as a bug fix
|
||||||
|
chore: mark this pull request as a maintenance item
|
||||||
|
|
||||||
|
To auto merge this pull request when it was approved
|
||||||
|
by another member of the organization: set the label `auto-merge`
|
||||||
|
-->
|
||||||
|
## Problem
|
||||||
|
A short description of the problem this PR is addressing.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
A short description of the chosen method to resolve the problem
|
||||||
|
with an overview of the logic and implementation details when needed.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
Other notes that you want to share but do not fit into _Problem_ or _Solution_.
|
||||||
|
|
||||||
36
cli/.github/cert.sh
vendored
Executable file
36
cli/.github/cert.sh
vendored
Executable file
@@ -0,0 +1,36 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p /tmp/letsencrypt
|
||||||
|
|
||||||
|
echo "Generating SSL certificate for hostnames: local.nhost.run, local.graphql.nhost.run, local.auth.nhost.run, local.storage.nhost.run, local.functions.nhost.run, local.mail.nhost.run"
|
||||||
|
docker run --rm \
|
||||||
|
--name certbot \
|
||||||
|
-e AWS_ACCESS_KEY_ID \
|
||||||
|
-e AWS_SECRET_ACCESS_KEY \
|
||||||
|
-e AWS_SESSION_TOKEN \
|
||||||
|
-e AWS_REGION \
|
||||||
|
-v /tmp/letsencrypt:/etc/letsencrypt \
|
||||||
|
-v /tmp/letsencrypt:/var/lib/letsencrypt \
|
||||||
|
certbot/dns-route53 certonly --dns-route53 --dns-route53-propagation-seconds 60 \
|
||||||
|
-d local.auth.nhost.run \
|
||||||
|
-d local.dashboard.nhost.run \
|
||||||
|
-d local.db.nhost.run \
|
||||||
|
-d local.functions.nhost.run \
|
||||||
|
-d local.graphql.nhost.run \
|
||||||
|
-d local.hasura.nhost.run \
|
||||||
|
-d local.mailhog.nhost.run \
|
||||||
|
-d local.storage.nhost.run \
|
||||||
|
-d *.auth.local.nhost.run \
|
||||||
|
-d *.dashboard.local.nhost.run \
|
||||||
|
-d *.db.local.nhost.run \
|
||||||
|
-d *.functions.local.nhost.run \
|
||||||
|
-d *.graphql.local.nhost.run \
|
||||||
|
-d *.hasura.local.nhost.run \
|
||||||
|
-d *.mailhog.local.nhost.run \
|
||||||
|
-d *.storage.local.nhost.run \
|
||||||
|
-m 'admin@nhost.io' --non-interactive --agree-tos --server https://acme-v02.api.letsencrypt.org/directory
|
||||||
|
|
||||||
|
sudo cp /tmp/letsencrypt/live/local.db.nhost.run/fullchain.pem ssl/.ssl/
|
||||||
|
sudo cp /tmp/letsencrypt/live/local.db.nhost.run/privkey.pem ssl/.ssl/
|
||||||
8
cli/.github/labeler.yml
vendored
Normal file
8
cli/.github/labeler.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
labels:
|
||||||
|
'feature':
|
||||||
|
- '^(?i:feat)'
|
||||||
|
- '^(?i:feature)'
|
||||||
|
'fix':
|
||||||
|
- '^(?i:fix)'
|
||||||
|
'chore':
|
||||||
|
- '^(?i:chore)'
|
||||||
39
cli/.github/release-drafter.yml
vendored
Normal file
39
cli/.github/release-drafter.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name-template: 'v$RESOLVED_VERSION'
|
||||||
|
tag-template: 'v$RESOLVED_VERSION'
|
||||||
|
categories:
|
||||||
|
- title: '🚀 Features'
|
||||||
|
label: 'feature'
|
||||||
|
- title: '🐛 Bug Fixes'
|
||||||
|
label: 'fix'
|
||||||
|
- title: '🧰 Maintenance'
|
||||||
|
label: 'chore'
|
||||||
|
change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
|
||||||
|
change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
|
||||||
|
version-resolver:
|
||||||
|
major:
|
||||||
|
labels:
|
||||||
|
- 'major'
|
||||||
|
minor:
|
||||||
|
labels:
|
||||||
|
- 'minor'
|
||||||
|
patch:
|
||||||
|
labels:
|
||||||
|
- 'patch'
|
||||||
|
default: patch
|
||||||
|
autolabeler:
|
||||||
|
- label: 'feature'
|
||||||
|
title:
|
||||||
|
- '/^feat/i'
|
||||||
|
- '/^feature/i'
|
||||||
|
- label: 'fix'
|
||||||
|
title:
|
||||||
|
- '/^fix/i'
|
||||||
|
- label: 'chore'
|
||||||
|
title:
|
||||||
|
- '/^chore/i'
|
||||||
|
prerelease: true
|
||||||
|
template: |
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
$CHANGES
|
||||||
|
|
||||||
16
cli/.github/stale.yml
vendored
Normal file
16
cli/.github/stale.yml
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# Configuration for probot-stale - https://github.com/probot/stale
|
||||||
|
|
||||||
|
daysUntilStale: 180
|
||||||
|
daysUntilClose: 7
|
||||||
|
limitPerRun: 30
|
||||||
|
onlyLabels: []
|
||||||
|
exemptLabels: []
|
||||||
|
|
||||||
|
exemptProjects: false
|
||||||
|
exemptMilestones: false
|
||||||
|
exemptAssignees: false
|
||||||
|
staleLabel: stale
|
||||||
|
markComment: >
|
||||||
|
This issue has been automatically marked as stale because it has not had
|
||||||
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
|
for your contributions.
|
||||||
17
cli/.github/workflows/assign_labels.yml
vendored
Normal file
17
cli/.github/workflows/assign_labels.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# this workflow will run on all pull requests opened but in the context of the base of the pull request.
|
||||||
|
on:
|
||||||
|
pull_request_target:
|
||||||
|
types: [opened]
|
||||||
|
|
||||||
|
name: "assign labels"
|
||||||
|
jobs:
|
||||||
|
# labeler will label pull requests based on their title.
|
||||||
|
# the configuration is at .github/labeler.yml.
|
||||||
|
label_pull_request:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Label Pull Request
|
||||||
|
uses: jimschubert/labeler-action@v2
|
||||||
|
with:
|
||||||
|
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
53
cli/.github/workflows/build-cert-weekly.yaml.disabled
vendored
Normal file
53
cli/.github/workflows/build-cert-weekly.yaml.disabled
vendored
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
---
|
||||||
|
name: "build certificate weekly"
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 0 * * 1'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure aws
|
||||||
|
uses: aws-actions/configure-aws-credentials@v2
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::796351718684:role/github-actions-nhost-cli
|
||||||
|
aws-region: eu-central-1
|
||||||
|
|
||||||
|
- name: fetch let's encrypt cert
|
||||||
|
id: certs
|
||||||
|
run: |
|
||||||
|
.github/cert.sh
|
||||||
|
|
||||||
|
echo "CERT_FULL_CHAIN<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
sudo cat /tmp/letsencrypt/live/local.db.nhost.run/fullchain.pem >> "$GITHUB_OUTPUT"
|
||||||
|
echo EOF >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
echo "CERT_PRIV_KEY<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
sudo cat /tmp/letsencrypt/live/local.db.nhost.run/privkey.pem >> "$GITHUB_OUTPUT"
|
||||||
|
echo EOF >> $GITHUB_OUTPUT
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- uses: hmanzur/actions-set-secret@v2.0.0
|
||||||
|
with:
|
||||||
|
name: 'CERT_FULL_CHAIN'
|
||||||
|
value: "${{ steps.certs.outputs.CERT_FULL_CHAIN }}"
|
||||||
|
repository: nhost/cli
|
||||||
|
token: ${{ secrets.GH_PAT }}
|
||||||
|
|
||||||
|
- uses: hmanzur/actions-set-secret@v2.0.0
|
||||||
|
with:
|
||||||
|
name: 'CERT_PRIV_KEY'
|
||||||
|
value: "${{ steps.certs.outputs.CERT_PRIV_KEY }}"
|
||||||
|
repository: nhost/cli
|
||||||
|
token: ${{ secrets.GH_PAT }}
|
||||||
27
cli/.github/workflows/checks.yaml
vendored
Normal file
27
cli/.github/workflows/checks.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "check and build"
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
uses: ./.github/workflows/wf_check.yaml
|
||||||
|
secrets:
|
||||||
|
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||||
|
|
||||||
|
build_artifacts:
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
GOOS: ["darwin", "linux"]
|
||||||
|
GOARCH: ["amd64", "arm64"]
|
||||||
|
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||||
|
with:
|
||||||
|
GOOS: ${{ matrix.GOOS }}
|
||||||
|
GOARCH: ${{ matrix.GOARCH }}
|
||||||
|
VERSION: ${{ github.sha }}
|
||||||
|
secrets:
|
||||||
|
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||||
56
cli/.github/workflows/codeql-analysis.yml
vendored
Normal file
56
cli/.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: "CodeQL"
|
||||||
|
|
||||||
|
on:
|
||||||
|
push: {}
|
||||||
|
pull_request: {}
|
||||||
|
schedule:
|
||||||
|
- cron: '20 23 * * 3'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
actions: read
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
language: [ 'go' ]
|
||||||
|
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||||
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Initializes the CodeQL tools for scanning.
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v2
|
||||||
|
with:
|
||||||
|
languages: ${{ matrix.language }}
|
||||||
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
|
# By default, queries listed here will override any specified in a config file.
|
||||||
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
|
|
||||||
|
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||||
|
# If this step fails, then you should remove it and run the build manually (see below)
|
||||||
|
- name: Autobuild
|
||||||
|
uses: github/codeql-action/autobuild@v2
|
||||||
|
|
||||||
|
# ℹ️ Command-line programs to run using the OS shell.
|
||||||
|
# 📚 https://git.io/JvXDl
|
||||||
|
|
||||||
|
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||||
|
# and modify them (or add more) to build your code if your project
|
||||||
|
# uses a compiled language
|
||||||
|
|
||||||
|
#- run: |
|
||||||
|
# make bootstrap
|
||||||
|
# make release
|
||||||
|
|
||||||
|
- name: Perform CodeQL Analysis
|
||||||
|
uses: github/codeql-action/analyze@v2
|
||||||
27
cli/.github/workflows/gen_ai_review.yaml
vendored
Normal file
27
cli/.github/workflows/gen_ai_review.yaml
vendored
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: "gen: AI review"
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, reopened, ready_for_review]
|
||||||
|
issue_comment:
|
||||||
|
jobs:
|
||||||
|
pr_agent_job:
|
||||||
|
if: ${{ github.event.sender.type != 'Bot' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
pull-requests: write
|
||||||
|
name: Run pr agent on every pull request, respond to user comments
|
||||||
|
steps:
|
||||||
|
- name: PR Agent action step
|
||||||
|
id: pragent
|
||||||
|
uses: Codium-ai/pr-agent@v0.29
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
|
config.max_model_tokens: 100000
|
||||||
|
config.model: "anthropic/claude-sonnet-4-20250514"
|
||||||
|
config.model_turbo: "anthropic/claude-sonnet-4-20250514"
|
||||||
|
ignore.glob: "['vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"
|
||||||
91
cli/.github/workflows/gen_schedule_update_deps.yaml
vendored
Normal file
91
cli/.github/workflows/gen_schedule_update_deps.yaml
vendored
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
---
|
||||||
|
name: "gen: update depenendencies"
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 2 1 2,5,8,11 *'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
run:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Configure aws
|
||||||
|
uses: aws-actions/configure-aws-credentials@v4
|
||||||
|
with:
|
||||||
|
role-to-assume: arn:aws:iam::${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
|
||||||
|
aws-region: eu-central-1
|
||||||
|
|
||||||
|
- uses: nixbuild/nix-quick-install-action@v26
|
||||||
|
with:
|
||||||
|
nix_version: 2.16.2
|
||||||
|
nix_conf: |
|
||||||
|
experimental-features = nix-command flakes
|
||||||
|
sandbox = false
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
|
substituters = https://cache.nixos.org/?priority=40 s3://nhost-nix-cache?region=eu-central-1&priority=50
|
||||||
|
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||||
|
|
||||||
|
- name: Cache nix store
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: /nix
|
||||||
|
key: nix-update-deps-${{ hashFiles('flakes.nix', 'flake.lock') }}
|
||||||
|
|
||||||
|
- name: Update nix flakes
|
||||||
|
run: nix flake update
|
||||||
|
|
||||||
|
- uses: shaunco/ssh-agent@git-repo-mapping
|
||||||
|
with:
|
||||||
|
ssh-private-key: |
|
||||||
|
${{ secrets.NHOST_BE_DEPLOY_SSH_PRIVATE_KEY}}
|
||||||
|
repo-mappings: |
|
||||||
|
github.com/nhost/be
|
||||||
|
|
||||||
|
- name: Update golang dependencies
|
||||||
|
run: |
|
||||||
|
export GOPRIVATE=github.com/nhost/be
|
||||||
|
nix develop -c bash -c "
|
||||||
|
go mod tidy
|
||||||
|
go get -u $(cat go.mod | grep nhost\/be | tr ' ' '@') ./...
|
||||||
|
go mod tidy
|
||||||
|
go mod vendor
|
||||||
|
"
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v6
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: Update dependencies
|
||||||
|
committer: GitHub <noreply@github.com>
|
||||||
|
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
|
||||||
|
signoff: false
|
||||||
|
branch: automated/update-deps
|
||||||
|
delete-branch: true
|
||||||
|
title: '[Scheduled] Update dependencies'
|
||||||
|
body: |
|
||||||
|
Dependencies updated
|
||||||
|
|
||||||
|
Note - If you see this PR and the checks haven't run, close and reopen the PR. See https://github.com/peter-evans/create-pull-request/blob/main/docs/concepts-guidelines.md#triggering-further-workflow-runs
|
||||||
|
labels: |
|
||||||
|
dependencies
|
||||||
|
draft: false
|
||||||
|
|
||||||
|
- name: "Cache nix store on s3"
|
||||||
|
run: |
|
||||||
|
echo ${{ secrets.NIX_CACHE_PRIV_KEY }} > cache-priv-key.pem
|
||||||
|
nix build .\#devShells.x86_64-linux.default
|
||||||
|
nix store sign --key-file cache-priv-key.pem --all
|
||||||
|
nix copy --to s3://nhost-nix-cache\?region=eu-central-1 .\#devShells.x86_64-linux.default
|
||||||
|
|
||||||
|
- run: rm cache-priv-key.pem
|
||||||
|
if: always()
|
||||||
35
cli/.github/workflows/release.yaml
vendored
Normal file
35
cli/.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: "release"
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
uses: ./.github/workflows/wf_check.yaml
|
||||||
|
secrets:
|
||||||
|
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||||
|
|
||||||
|
build_artifacts:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
GOOS: ["darwin", "linux"]
|
||||||
|
GOARCH: ["amd64", "arm64"]
|
||||||
|
uses: ./.github/workflows/wf_build_artifacts.yaml
|
||||||
|
with:
|
||||||
|
GOOS: ${{ matrix.GOOS }}
|
||||||
|
GOARCH: ${{ matrix.GOARCH }}
|
||||||
|
VERSION: ${{ github.ref_name }}
|
||||||
|
secrets:
|
||||||
|
NHOST_PAT: ${{ secrets.NHOST_PAT }}
|
||||||
|
|
||||||
|
publish:
|
||||||
|
uses: ./.github/workflows/wf_publish.yaml
|
||||||
|
needs:
|
||||||
|
- tests
|
||||||
|
- build_artifacts
|
||||||
|
with:
|
||||||
|
VERSION: ${{ github.ref_name }}
|
||||||
|
secrets:
|
||||||
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
17
cli/.github/workflows/release_drafter.yml
vendored
Normal file
17
cli/.github/workflows/release_drafter.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: "release drafter"
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# draft your next release notes as pull requests are merged into "master"
|
||||||
|
# the configuration is at /.github/release-drafter.yml.
|
||||||
|
update_release_draft:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: release-drafter/release-drafter@v5
|
||||||
|
with:
|
||||||
|
config-name: release-drafter.yml
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
89
cli/.github/workflows/wf_build_artifacts.yaml
vendored
Normal file
89
cli/.github/workflows/wf_build_artifacts.yaml
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
---
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
GIT_REF:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
VERSION:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
GOOS:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
GOARCH:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
secrets:
|
||||||
|
NHOST_PAT:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
artifacts:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Check out repository"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v27
|
||||||
|
with:
|
||||||
|
install_url: "https://releases.nixos.org/nix/nix-2.22.3/install"
|
||||||
|
install_options: "--no-daemon"
|
||||||
|
extra_nix_config: |
|
||||||
|
experimental-features = nix-command flakes
|
||||||
|
sandbox = false
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
|
substituters = https://cache.nixos.org/?priority=40
|
||||||
|
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
|
||||||
|
|
||||||
|
- name: Compute common env vars
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
echo "VERSION=$(make get-version VERSION=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: "Build artifact"
|
||||||
|
run: |
|
||||||
|
make build ARCH=${{ inputs.GOARCH }} OS=${{ inputs.GOOS }}
|
||||||
|
find -L result -type f -exec cp {} nhost-cli \;
|
||||||
|
|
||||||
|
- name: "Push artifact to artifact repository"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cli-${{ steps.vars.outputs.VERSION }}-${{ inputs.GOOS }}-${{ inputs.GOARCH }}
|
||||||
|
path: nhost-cli
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: "Build docker-image"
|
||||||
|
run: |
|
||||||
|
make build-docker-image ARCH=${{ inputs.GOARCH }}
|
||||||
|
if: ${{ ( inputs.GOOS == 'linux' ) }}
|
||||||
|
|
||||||
|
- name: "Create a new project"
|
||||||
|
run: |
|
||||||
|
export NHOST_DOMAIN=staging.nhost.run
|
||||||
|
export NHOST_CONFIGSERVER_IMAGE=nhost/cli:${{ steps.vars.outputs.VERSION }}
|
||||||
|
|
||||||
|
mkdir new-project
|
||||||
|
cd new-project
|
||||||
|
../nhost-cli login --pat ${{ secrets.NHOST_PAT }}
|
||||||
|
../nhost-cli init
|
||||||
|
../nhost-cli up --down-on-error
|
||||||
|
../nhost-cli down
|
||||||
|
if: ${{ ( inputs.GOOS == 'linux' && inputs.GOARCH == 'amd64' ) }}
|
||||||
|
|
||||||
|
- name: "Push docker-image to artifact repository"
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: cli-docker-image-${{ steps.vars.outputs.VERSION }}-${{ inputs.GOOS }}-${{ inputs.GOARCH }}
|
||||||
|
path: result
|
||||||
|
retention-days: 7
|
||||||
|
if: ${{ ( inputs.GOOS == 'linux' ) }}
|
||||||
42
cli/.github/workflows/wf_check.yaml
vendored
Normal file
42
cli/.github/workflows/wf_check.yaml
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
---
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
GIT_REF:
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
secrets:
|
||||||
|
NHOST_PAT:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Check out repository"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- uses: cachix/install-nix-action@v27
|
||||||
|
with:
|
||||||
|
install_url: "https://releases.nixos.org/nix/nix-2.22.3/install"
|
||||||
|
install_options: "--no-daemon"
|
||||||
|
extra_nix_config: |
|
||||||
|
experimental-features = nix-command flakes
|
||||||
|
sandbox = false
|
||||||
|
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||||
|
substituters = https://cache.nixos.org/?priority=40
|
||||||
|
trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=
|
||||||
|
|
||||||
|
- name: "Run checks"
|
||||||
|
run: |
|
||||||
|
export NHOST_PAT=${{ secrets.NHOST_PAT }}
|
||||||
|
make check
|
||||||
93
cli/.github/workflows/wf_publish.yaml
vendored
Normal file
93
cli/.github/workflows/wf_publish.yaml
vendored
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
---
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
VERSION:
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
secrets:
|
||||||
|
DOCKER_USERNAME:
|
||||||
|
required: true
|
||||||
|
DOCKER_PASSWORD:
|
||||||
|
required: true
|
||||||
|
|
||||||
|
name: release
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Check out repository"
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
ref: ${{ inputs.GIT_REF }}
|
||||||
|
submodules: true
|
||||||
|
|
||||||
|
- name: Compute common env vars
|
||||||
|
id: vars
|
||||||
|
run: |
|
||||||
|
echo "VERSION=$(make get-version VERSION=${{ inputs.VERSION }})" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: "Get artifacts"
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ~/artifacts
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Upload docker images
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
export VERSION=${{ steps.vars.outputs.VERSION }}
|
||||||
|
export CONTAINER_NAME=nhost/cli
|
||||||
|
|
||||||
|
skopeo copy --insecure-policy \
|
||||||
|
dir:/home/runner/artifacts/cli-docker-image-$VERSION-linux-amd64 \
|
||||||
|
docker-daemon:$CONTAINER_NAME:$VERSION-amd64
|
||||||
|
docker push $CONTAINER_NAME:$VERSION-amd64
|
||||||
|
|
||||||
|
skopeo copy --insecure-policy \
|
||||||
|
dir:/home/runner/artifacts/cli-docker-image-$VERSION-linux-arm64 \
|
||||||
|
docker-daemon:$CONTAINER_NAME:$VERSION-arm64
|
||||||
|
docker push $CONTAINER_NAME:$VERSION-arm64
|
||||||
|
|
||||||
|
docker manifest create \
|
||||||
|
$CONTAINER_NAME:$VERSION \
|
||||||
|
--amend $CONTAINER_NAME:$VERSION-amd64 \
|
||||||
|
--amend $CONTAINER_NAME:$VERSION-arm64
|
||||||
|
docker manifest push $CONTAINER_NAME:$VERSION
|
||||||
|
|
||||||
|
- name: Upload assets
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
export VERSION=${{ steps.vars.outputs.VERSION }}
|
||||||
|
|
||||||
|
mkdir upload
|
||||||
|
|
||||||
|
find ~/artifacts -type f -name "nhost-cli" -exec bash -c 'chmod +x "$0" && mv "$0" "${0//nhost-cli/cli}"' {} \;
|
||||||
|
|
||||||
|
tar cvzf upload/cli-$VERSION-darwin-amd64.tar.gz -C ~/artifacts/cli-$VERSION-darwin-amd64 cli
|
||||||
|
tar cvzf upload/cli-$VERSION-darwin-arm64.tar.gz -C ~/artifacts/cli-$VERSION-darwin-arm64 cli
|
||||||
|
tar cvzf upload/cli-$VERSION-linux-amd64.tar.gz -C ~/artifacts/cli-$VERSION-linux-amd64 cli
|
||||||
|
tar cvzf upload/cli-$VERSION-linux-arm64.tar.gz -C ~/artifacts/cli-$VERSION-linux-arm64 cli
|
||||||
|
|
||||||
|
cd upload
|
||||||
|
find . -type f -exec sha256sum {} + > ../checksums.txt
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
cat checksums.txt
|
||||||
|
|
||||||
|
gh release upload \
|
||||||
|
--clobber "${{ github.ref_name }}" \
|
||||||
|
./upload/* checksums.txt
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
## [cli@1.32.1] - 2025-09-29
|
|
||||||
|
|
||||||
### ⚙️ Miscellaneous Tasks
|
|
||||||
|
|
||||||
- *(ci)* Minor improvements to the ci (#3527)
|
|
||||||
- *(cli)* Update schema (#3529)
|
|
||||||
|
|
||||||
@@ -9,7 +9,6 @@ module.exports = {
|
|||||||
project: './tsconfig.json',
|
project: './tsconfig.json',
|
||||||
},
|
},
|
||||||
ignorePatterns: [
|
ignorePatterns: [
|
||||||
'src/utils/hasura-api/generated/',
|
|
||||||
'**/.eslintrc.js',
|
'**/.eslintrc.js',
|
||||||
'**/prettier.config.js',
|
'**/prettier.config.js',
|
||||||
'**/next.config.js',
|
'**/next.config.js',
|
||||||
|
|||||||
@@ -1,37 +1,3 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
## [@nhost/dashboard@2.38.2] - 2025-09-30
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
|
|
||||||
- *(dashboard)* Update remote schemas url tooltip (#3540)
|
|
||||||
|
|
||||||
## [@nhost/dashboard@2.38.1] - 2025-09-30
|
|
||||||
|
|
||||||
### 🐛 Bug Fixes
|
|
||||||
|
|
||||||
- *(dashboard)* Delay generating auth service url when creating users (#3530)
|
|
||||||
|
|
||||||
|
|
||||||
### ⚙️ Miscellaneous Tasks
|
|
||||||
|
|
||||||
- *(ci)* Bump aws-actions/configure-aws-credentials from 4 to 5 (#3522)
|
|
||||||
|
|
||||||
## [@nhost/dashboard@2.38.0] - 2025-09-29
|
|
||||||
|
|
||||||
### 🚀 Features
|
|
||||||
|
|
||||||
- *(dashboard)* Add remote schemas (#3299)
|
|
||||||
- *(dashboard)* Datatable column header redesign (#3500)
|
|
||||||
|
|
||||||
|
|
||||||
### ⚙️ Miscellaneous Tasks
|
|
||||||
|
|
||||||
- *(ci)* Add e2e remote schema name repository variable to CI workflow (#3502)
|
|
||||||
- *(dashboard)* Fix defects in basetable form and add comment to columns (#3475)
|
|
||||||
|
|
||||||
# @nhost/dashboard
|
# @nhost/dashboard
|
||||||
|
|
||||||
## 2.37.0
|
## 2.37.0
|
||||||
|
|||||||
@@ -44,9 +44,3 @@ export const TEST_PERSONAL_ORG_SLUG = process.env.NHOST_TEST_PERSONAL_ORG_SLUG!;
|
|||||||
const freeUserEmails = process.env.NHOST_TEST_FREE_USER_EMAILS!;
|
const freeUserEmails = process.env.NHOST_TEST_FREE_USER_EMAILS!;
|
||||||
|
|
||||||
export const TEST_FREE_USER_EMAILS: string[] = JSON.parse(freeUserEmails);
|
export const TEST_FREE_USER_EMAILS: string[] = JSON.parse(freeUserEmails);
|
||||||
|
|
||||||
/**
|
|
||||||
* Name of the remote schema serverless function to test against.
|
|
||||||
*/
|
|
||||||
export const TEST_PROJECT_REMOTE_SCHEMA_NAME =
|
|
||||||
process.env.NHOST_TEST_PROJECT_REMOTE_SCHEMA_NAME!;
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import {
|
|
||||||
TEST_ORGANIZATION_SLUG,
|
|
||||||
TEST_PROJECT_REMOTE_SCHEMA_NAME,
|
|
||||||
TEST_PROJECT_SUBDOMAIN,
|
|
||||||
} from '@/e2e/env';
|
|
||||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
|
||||||
import { faker } from '@faker-js/faker';
|
|
||||||
import { snakeCase } from 'snake-case';
|
|
||||||
|
|
||||||
const REMOTE_SCHEMA_TEST_URL = `https://${TEST_PROJECT_SUBDOMAIN}.functions.eu-central-1.staging.nhost.run/v1/${TEST_PROJECT_REMOTE_SCHEMA_NAME}`;
|
|
||||||
|
|
||||||
test.describe('Remote Schemas', () => {
|
|
||||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
|
||||||
const remoteSchemasRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/graphql/remote-schemas`;
|
|
||||||
await page.goto(remoteSchemasRoute);
|
|
||||||
await page.waitForURL(remoteSchemasRoute);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create and delete a remote schema from URL', async ({
|
|
||||||
authenticatedNhostPage: page,
|
|
||||||
}) => {
|
|
||||||
await page.getByRole('button', { name: /add remote schema/i }).click();
|
|
||||||
await expect(page.getByText(/create a new remote schema/i)).toBeVisible();
|
|
||||||
|
|
||||||
const schemaName = snakeCase(`e2e ${faker.lorem.words(2)}`);
|
|
||||||
|
|
||||||
await page.getByPlaceholder(/remote schema name/i).fill(schemaName);
|
|
||||||
await page
|
|
||||||
.getByPlaceholder(/graphql-service\.example\.com/i)
|
|
||||||
.fill(REMOTE_SCHEMA_TEST_URL);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /create/i }).click();
|
|
||||||
|
|
||||||
await page.waitForSelector(
|
|
||||||
'div:has-text("The remote schema has been created successfully.")',
|
|
||||||
);
|
|
||||||
|
|
||||||
const detailsUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/graphql/remote-schemas/${schemaName}`;
|
|
||||||
await page.waitForURL(detailsUrl);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: schemaName, exact: true }),
|
|
||||||
).toBeVisible();
|
|
||||||
|
|
||||||
const schemaLink = page.getByRole('link', {
|
|
||||||
name: schemaName,
|
|
||||||
exact: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await schemaLink.hover();
|
|
||||||
await page
|
|
||||||
.getByRole('listitem')
|
|
||||||
.filter({ hasText: schemaName })
|
|
||||||
.getByRole('button')
|
|
||||||
.click();
|
|
||||||
|
|
||||||
await page.getByRole('menuitem', { name: /delete remote schema/i }).click();
|
|
||||||
await page.getByRole('button', { name: /^delete$/i }).click();
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByRole('link', { name: schemaName, exact: true }),
|
|
||||||
).toHaveCount(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,11 +10,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint --max-warnings 0",
|
"lint": "next lint --max-warnings 0",
|
||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:watch": "vitest",
|
|
||||||
"generate": "echo 'This needs to be fixed.'",
|
"generate": "echo 'This needs to be fixed.'",
|
||||||
"codegen": "DOTENV_CONFIG_PATH=./.env.local graphql-codegen -r dotenv/config --config graphql.config.yaml --errors-only",
|
"codegen": "DOTENV_CONFIG_PATH=./.env.local graphql-codegen -r dotenv/config --config graphql.config.yaml --errors-only",
|
||||||
"codegen-graphite": "graphql-codegen --config graphite.graphql.config.yaml --errors-only",
|
"codegen-graphite": "graphql-codegen --config graphite.graphql.config.yaml --errors-only",
|
||||||
"codegen-hasura-api": "orval --config src/utils/hasura-api/orval.config.ts",
|
|
||||||
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
|
||||||
"storybook": "start-storybook -p 6006 -s public",
|
"storybook": "start-storybook -p 6006 -s public",
|
||||||
"build-storybook": "build-storybook",
|
"build-storybook": "build-storybook",
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
|
||||||
"@radix-ui/react-tabs": "^1.1.3",
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@segment/analytics-next": "^1.77.0",
|
"@segment/analytics-next": "^1.77.0",
|
||||||
@@ -197,7 +194,6 @@
|
|||||||
"msw": "^1.3.5",
|
"msw": "^1.3.5",
|
||||||
"msw-storybook-addon": "^1.10.0",
|
"msw-storybook-addon": "^1.10.0",
|
||||||
"node-fetch": "^3.3.2",
|
"node-fetch": "^3.3.2",
|
||||||
"orval": "^7.11.2",
|
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prettier-plugin-organize-imports": "^4.1.0",
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
fullyParallel: false,
|
fullyParallel: false,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
retries: 2,
|
retries: 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
1816
dashboard/pnpm-lock.yaml
generated
1816
dashboard/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -64,11 +64,7 @@ export default function AuthenticatedLayout({
|
|||||||
router.push('/orgs/local/projects/local');
|
router.push('/orgs/local/projects/local');
|
||||||
}, [isPlatform, router]);
|
}, [isPlatform, router]);
|
||||||
|
|
||||||
if (
|
if ((isPlatform && isLoading) || isSigningOut) {
|
||||||
(isPlatform && isLoading) ||
|
|
||||||
isSigningOut ||
|
|
||||||
(isPlatform && !isAuthenticated)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout className="h-full" {...props}>
|
<BaseLayout className="h-full" {...props}>
|
||||||
<Header className="flex max-h-[59px] flex-auto py-1" />
|
<Header className="flex max-h-[59px] flex-auto py-1" />
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import OrgPagesComboBox from './OrgPagesComboBox';
|
import OrgPagesComboBox from './OrgPagesComboBox';
|
||||||
import OrgsComboBox from './OrgsComboBox';
|
import OrgsComboBox from './OrgsComboBox';
|
||||||
import ProjectGraphQLPagesComboBox from './ProjectGraphQLPagesComboBox';
|
|
||||||
import ProjectPagesComboBox from './ProjectPagesComboBox';
|
import ProjectPagesComboBox from './ProjectPagesComboBox';
|
||||||
import ProjectsComboBox from './ProjectsComboBox';
|
import ProjectsComboBox from './ProjectsComboBox';
|
||||||
import ProjectSettingsPagesComboBox from './ProjectSettingsPagesComboBox';
|
import ProjectSettingsPagesComboBox from './ProjectSettingsPagesComboBox';
|
||||||
@@ -26,7 +25,6 @@ export default function BreadcrumbNav() {
|
|||||||
// Identify project and settings pages based on the URL pattern
|
// Identify project and settings pages based on the URL pattern
|
||||||
const projectPage = pathSegments[3] || null;
|
const projectPage = pathSegments[3] || null;
|
||||||
const isSettingsPage = pathSegments[5] === 'settings';
|
const isSettingsPage = pathSegments[5] === 'settings';
|
||||||
const isGraphQLPage = pathSegments[5] === 'graphql';
|
|
||||||
|
|
||||||
const showBreadcrumbs = !['/', '/orgs/verify'].includes(route);
|
const showBreadcrumbs = !['/', '/orgs/verify'].includes(route);
|
||||||
|
|
||||||
@@ -83,21 +81,6 @@ export default function BreadcrumbNav() {
|
|||||||
</BreadcrumbItem>
|
</BreadcrumbItem>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isGraphQLPage && (
|
|
||||||
<>
|
|
||||||
<BreadcrumbSeparator>
|
|
||||||
<Slash
|
|
||||||
strokeWidth={3.5}
|
|
||||||
className="text-muted-foreground/50"
|
|
||||||
/>
|
|
||||||
</BreadcrumbSeparator>
|
|
||||||
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<ProjectGraphQLPagesComboBox />
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
|
|||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/v3/command';
|
|
||||||
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/v3/popover';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
type Option = {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
route: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const projectGraphQLPages = [
|
|
||||||
{
|
|
||||||
name: 'Playground',
|
|
||||||
slug: 'playground',
|
|
||||||
route: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Remote Schemas',
|
|
||||||
slug: 'remote-schemas',
|
|
||||||
route: 'remote-schemas',
|
|
||||||
},
|
|
||||||
].map((item) => ({
|
|
||||||
label: item.name,
|
|
||||||
value: item.slug,
|
|
||||||
route: item.route,
|
|
||||||
}));
|
|
||||||
|
|
||||||
export default function ProjectGraphQLPagesComboBox() {
|
|
||||||
const {
|
|
||||||
query: { orgSlug, appSubdomain },
|
|
||||||
push,
|
|
||||||
asPath,
|
|
||||||
} = useRouter();
|
|
||||||
|
|
||||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
|
||||||
const isGraphQLPage = pathSegments.includes('graphql');
|
|
||||||
const graphQLPageFromUrl = isGraphQLPage
|
|
||||||
? pathSegments[6] || 'playground'
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const selectedGraphQLPageFromUrl = projectGraphQLPages.find(
|
|
||||||
(item) => item.value === graphQLPageFromUrl,
|
|
||||||
);
|
|
||||||
const [selectedGraphQLPage, setSelectedGraphQLPage] = useState<Option | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedGraphQLPageFromUrl) {
|
|
||||||
setSelectedGraphQLPage({
|
|
||||||
label: selectedGraphQLPageFromUrl.label,
|
|
||||||
value: selectedGraphQLPageFromUrl.value,
|
|
||||||
route: selectedGraphQLPageFromUrl.route,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [selectedGraphQLPageFromUrl]);
|
|
||||||
|
|
||||||
const options: Option[] = projectGraphQLPages.map((page) => ({
|
|
||||||
label: page.label,
|
|
||||||
value: page.value,
|
|
||||||
route: page.route,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="justify-start gap-2 bg-background text-foreground hover:bg-accent dark:hover:bg-muted"
|
|
||||||
>
|
|
||||||
{selectedGraphQLPage ? (
|
|
||||||
<div>{selectedGraphQLPage.label}</div>
|
|
||||||
) : (
|
|
||||||
<>Select a page</>
|
|
||||||
)}
|
|
||||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" side="bottom" align="start">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Select a page..." />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{options.map((option) => (
|
|
||||||
<CommandItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.label}
|
|
||||||
onSelect={() => {
|
|
||||||
setSelectedGraphQLPage(option);
|
|
||||||
setOpen(false);
|
|
||||||
push(
|
|
||||||
`/orgs/${orgSlug}/projects/${appSubdomain}/graphql/${option.route}/`,
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'mr-2 h-4 w-4',
|
|
||||||
selectedGraphQLPage?.value === option.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row items-center gap-2">
|
|
||||||
<span className="max-w-52 truncate">{option.label}</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -160,24 +160,12 @@ const projectSettingsPages = [
|
|||||||
{ name: 'Configuration Editor', slug: 'editor', route: 'editor' },
|
{ name: 'Configuration Editor', slug: 'editor', route: 'editor' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const projectGraphQLPages = [
|
|
||||||
{
|
|
||||||
name: 'Playground',
|
|
||||||
slug: 'playground',
|
|
||||||
route: 'graphql',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Remote Schemas',
|
|
||||||
slug: 'remote-schemas',
|
|
||||||
route: 'graphql/remote-schemas',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const createOrganization = (org: Org) => {
|
const createOrganization = (org: Org) => {
|
||||||
const isNotPlatform = !getIsPlatform();
|
const isNotPlatform = !getIsPlatform();
|
||||||
const configServerVariableNotSet = getConfigServerUrl() === '';
|
const configServerVariableNotSet = getConfigServerUrl() === '';
|
||||||
const shouldDisableSettings = isNotPlatform && configServerVariableNotSet;
|
const shouldDisableSettings = isNotPlatform && configServerVariableNotSet;
|
||||||
const shouldDisableGraphite = shouldDisableSettings;
|
const shouldDisableGraphite = shouldDisableSettings;
|
||||||
|
|
||||||
const result = {};
|
const result = {};
|
||||||
|
|
||||||
result[org.slug] = {
|
result[org.slug] = {
|
||||||
@@ -255,22 +243,13 @@ const createOrganization = (org: Org) => {
|
|||||||
result[`${org.slug}-${_app.subdomain}-${_page.slug}`] = {
|
result[`${org.slug}-${_app.subdomain}-${_page.slug}`] = {
|
||||||
index: `${org.slug}-${_app.subdomain}-${_page.slug}`,
|
index: `${org.slug}-${_app.subdomain}-${_page.slug}`,
|
||||||
canMove: false,
|
canMove: false,
|
||||||
isFolder:
|
isFolder: _page.name === 'Settings' && !shouldDisableSettings,
|
||||||
(_page.name === 'Settings' && !shouldDisableSettings) ||
|
children:
|
||||||
_page.name === 'GraphQL',
|
_page.name === 'Settings' && !shouldDisableSettings
|
||||||
children: (() => {
|
? projectSettingsPages.map(
|
||||||
if (_page.name === 'Settings' && !shouldDisableSettings) {
|
(p) => `${org.slug}-${_app.subdomain}-settings-${p.slug}`,
|
||||||
return projectSettingsPages.map(
|
)
|
||||||
(p) => `${org.slug}-${_app.subdomain}-settings-${p.slug}`,
|
: undefined,
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_page.name === 'GraphQL') {
|
|
||||||
return projectGraphQLPages.map(
|
|
||||||
(p) => `${org.slug}-${_app.subdomain}-graphql-${p.slug}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
})(),
|
|
||||||
data: {
|
data: {
|
||||||
name: _page.name,
|
name: _page.name,
|
||||||
icon: _page.icon,
|
icon: _page.icon,
|
||||||
@@ -306,20 +285,6 @@ const createOrganization = (org: Org) => {
|
|||||||
canRename: false,
|
canRename: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
projectGraphQLPages.forEach((p) => {
|
|
||||||
result[`${org.slug}-${_app.subdomain}-graphql-${p.slug}`] = {
|
|
||||||
index: `${org.slug}-${_app.subdomain}-graphql-${p.slug}`,
|
|
||||||
canMove: false,
|
|
||||||
isFolder: false,
|
|
||||||
children: undefined,
|
|
||||||
data: {
|
|
||||||
name: p.name,
|
|
||||||
targetUrl: `/orgs/${org.slug}/projects/${_app.subdomain}/${p.route}`,
|
|
||||||
},
|
|
||||||
canRename: false,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
result[`${org.slug}-settings`] = {
|
result[`${org.slug}-settings`] = {
|
||||||
@@ -471,12 +436,6 @@ export default function NavTree() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.data.name === 'GraphQL' && item.isFolder) {
|
|
||||||
if (!context.isExpanded) {
|
|
||||||
context.toggleExpandedState();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.data.type !== 'org') {
|
if (item.data.type !== 'org') {
|
||||||
context.focusItem();
|
context.focusItem();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
export function InlineCode({
|
|
||||||
children,
|
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: PropsWithChildren<{ className?: string }>) {
|
|
||||||
return (
|
|
||||||
<code
|
|
||||||
className={cn(
|
|
||||||
'relative rounded bg-[#eaedf0] px-1 font-mono text-[11px] dark:bg-[#2f363d]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
|
||||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root> & {
|
|
||||||
thumbClassName?: string;
|
|
||||||
}
|
|
||||||
>(({ className, thumbClassName, ...props }, ref) => (
|
|
||||||
<SwitchPrimitives.Root
|
|
||||||
className={cn(
|
|
||||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input dark:data-[state=checked]:bg-[#e5e5e5]',
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<SwitchPrimitives.Thumb
|
|
||||||
className={cn(
|
|
||||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-3px)] data-[state=unchecked]:translate-x-0 dark:data-[state=unchecked]:bg-[#e5e5e5]',
|
|
||||||
thumbClassName,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SwitchPrimitives.Root>
|
|
||||||
));
|
|
||||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
|
||||||
|
|
||||||
export { Switch };
|
|
||||||
@@ -12,7 +12,6 @@ import { ApplicationUnpausing } from '@/features/orgs/projects/common/components
|
|||||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
||||||
import { isEmptyValue } from '@/lib/utils';
|
|
||||||
import { useAuth } from '@/providers/Auth';
|
import { useAuth } from '@/providers/Auth';
|
||||||
import { ApplicationStatus } from '@/types/application';
|
import { ApplicationStatus } from '@/types/application';
|
||||||
import { getConfigServerUrl, isPlatform as isPlatformFn } from '@/utils/env';
|
import { getConfigServerUrl, isPlatform as isPlatformFn } from '@/utils/env';
|
||||||
@@ -189,15 +188,6 @@ function ProjectLayoutContent({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
isUserLoggedIn &&
|
|
||||||
isEmptyValue(project) &&
|
|
||||||
!loading &&
|
|
||||||
isEmptyValue(error)
|
|
||||||
) {
|
|
||||||
throw new Error('Could not load project. Please try again later.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
|
|||||||
@@ -65,39 +65,37 @@ export default function CreateUserForm({
|
|||||||
onDirtyStateChange(isDirty, location);
|
onDirtyStateChange(isDirty, location);
|
||||||
}, [isDirty, location, onDirtyStateChange]);
|
}, [isDirty, location, onDirtyStateChange]);
|
||||||
|
|
||||||
|
const baseAuthUrl =
|
||||||
|
isNotEmptyValue(project?.subdomain) && isNotEmptyValue(project?.region)
|
||||||
|
? generateAppServiceUrl(project!.subdomain, project!.region, 'auth')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const signUpUrl = `${baseAuthUrl}/signup/email-password`;
|
||||||
|
|
||||||
async function handleCreateUser({ email, password }: CreateUserFormValues) {
|
async function handleCreateUser({ email, password }: CreateUserFormValues) {
|
||||||
setCreateUserFormError(null);
|
setCreateUserFormError(null);
|
||||||
|
|
||||||
await execPromiseWithErrorToast(
|
await execPromiseWithErrorToast(
|
||||||
async () => {
|
async () => {
|
||||||
if (isNotEmptyValue(project)) {
|
await fetch(signUpUrl, {
|
||||||
const baseAuthUrl = generateAppServiceUrl(
|
method: 'POST',
|
||||||
project.subdomain,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
project.region,
|
body: JSON.stringify({ email, password }),
|
||||||
'auth',
|
}).then(async (res) => {
|
||||||
);
|
|
||||||
const signUpUrl = `${baseAuthUrl}/signup/email-password`;
|
|
||||||
|
|
||||||
const res = await fetch(signUpUrl, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ email, password }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (res.ok) {
|
||||||
if (res.status === 409) {
|
return data;
|
||||||
setError('email', { message: data?.message });
|
|
||||||
}
|
|
||||||
throw new Error(data?.message || 'Something went wrong.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit?.();
|
if (res.status === 409) {
|
||||||
|
setError('email', { message: data?.message });
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
throw new Error(data?.message || 'Something went wrong.');
|
||||||
}
|
});
|
||||||
throw new Error('Something went wrong. Please try again later.');
|
|
||||||
|
onSubmit?.();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loadingMessage: 'Creating user...',
|
loadingMessage: 'Creating user...',
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default as useGetMetadataResourceVersion } from './useGetMetadataResourceVersion';
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { fetchExportMetadata } from '@/features/orgs/projects/common/utils/fetchExportMetadata';
|
|
||||||
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
|
||||||
import type { ExportMetadataResponse } from '@/utils/hasura-api/generated/schemas';
|
|
||||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
|
|
||||||
export interface UseGetMetadataResourceVersionOptions {
|
|
||||||
/**
|
|
||||||
* Props passed to the underlying query hook.
|
|
||||||
*/
|
|
||||||
queryOptions?: UseQueryOptions<ExportMetadataResponse, unknown, number>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This hook is a wrapper around a fetch call that gets the metadata resource version.
|
|
||||||
*
|
|
||||||
* @param options - Options to use for the query.
|
|
||||||
* @returns The result of the query.
|
|
||||||
*/
|
|
||||||
export default function useGetMetadataResourceVersion({
|
|
||||||
queryOptions,
|
|
||||||
}: UseGetMetadataResourceVersionOptions = {}) {
|
|
||||||
const { project } = useProject();
|
|
||||||
|
|
||||||
const query = useQuery<ExportMetadataResponse, unknown, number>(
|
|
||||||
['export-metadata', project?.subdomain],
|
|
||||||
() => {
|
|
||||||
const appUrl = generateAppServiceUrl(
|
|
||||||
project!.subdomain,
|
|
||||||
project!.region,
|
|
||||||
'hasura',
|
|
||||||
);
|
|
||||||
|
|
||||||
const adminSecret = project?.config?.hasura.adminSecret!;
|
|
||||||
|
|
||||||
return fetchExportMetadata({ appUrl, adminSecret });
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...queryOptions,
|
|
||||||
select: (data) => data.resource_version,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return query;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import type { MetadataOperationOptions } from '@/features/orgs/projects/remote-schemas/types';
|
|
||||||
import { metadataOperation } from '@/utils/hasura-api/generated/default/default';
|
|
||||||
import type { ExportMetadataResponse } from '@/utils/hasura-api/generated/schemas';
|
|
||||||
|
|
||||||
export default async function fetchExportMetadata({
|
|
||||||
appUrl,
|
|
||||||
adminSecret,
|
|
||||||
}: MetadataOperationOptions): Promise<ExportMetadataResponse> {
|
|
||||||
try {
|
|
||||||
const response = await metadataOperation(
|
|
||||||
{
|
|
||||||
type: 'export_metadata',
|
|
||||||
version: 2,
|
|
||||||
args: {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
baseUrl: appUrl,
|
|
||||||
adminSecret,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error(response.data.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data as ExportMetadataResponse;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as fetchExportMetadata } from './fetchExportMetadata';
|
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import {
|
||||||
|
ControlledAutocomplete,
|
||||||
|
defaultFilterGroupedOptions,
|
||||||
|
} from '@/components/form/ControlledAutocomplete';
|
||||||
|
import { ControlledCheckbox } from '@/components/form/ControlledCheckbox';
|
||||||
|
import { Form } from '@/components/form/Form';
|
||||||
|
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
|
import { OptionBase } from '@/components/ui/v2/Option';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import type {
|
||||||
|
ColumnType,
|
||||||
|
DatabaseColumn,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import {
|
||||||
|
identityTypes,
|
||||||
|
postgresFunctions,
|
||||||
|
postgresTypeGroups,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||||
|
import type { DialogFormProps } from '@/types/common';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
import ForeignKeyEditor from './ForeignKeyEditor';
|
||||||
|
|
||||||
|
export type BaseColumnFormValues = DatabaseColumn;
|
||||||
|
|
||||||
|
export interface BaseColumnFormProps extends DialogFormProps {
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit: (values: BaseColumnFormValues) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Function to be called when the operation is cancelled.
|
||||||
|
*/
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Submit button text.
|
||||||
|
*
|
||||||
|
* @default 'Save'
|
||||||
|
*/
|
||||||
|
submitButtonText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const baseColumnValidationSchema = Yup.object().shape({
|
||||||
|
name: Yup.string()
|
||||||
|
.required('This field is required.')
|
||||||
|
.matches(
|
||||||
|
/^([A-Za-z]|_)+/i,
|
||||||
|
'Column name must start with a letter or underscore.',
|
||||||
|
)
|
||||||
|
.matches(
|
||||||
|
/^\w+$/i,
|
||||||
|
'Column name must contain only letters, numbers, or underscores.',
|
||||||
|
),
|
||||||
|
type: Yup.object()
|
||||||
|
.shape({ value: Yup.string().required() })
|
||||||
|
.required('This field is required.')
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function BaseColumnForm({
|
||||||
|
onSubmit: handleExternalSubmit,
|
||||||
|
onCancel,
|
||||||
|
submitButtonText = 'Save',
|
||||||
|
location,
|
||||||
|
}: BaseColumnFormProps) {
|
||||||
|
const { onDirtyStateChange } = useDialog();
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
register,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
formState: { errors, isSubmitting, dirtyFields },
|
||||||
|
} = useFormContext<BaseColumnFormValues>();
|
||||||
|
|
||||||
|
// Learn more: https://github.com/thundermiracle/mobx-json/issues/46
|
||||||
|
const [defaultValueInputText, setDefaultValueInputText] = useState(
|
||||||
|
() => getValues('defaultValue.label') || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const isIdentity = useWatch({ name: 'isIdentity' });
|
||||||
|
const type = useWatch({ name: 'type' });
|
||||||
|
const foreignKeyRelation = useWatch({ name: 'foreignKeyRelation' });
|
||||||
|
const availableFunctions = (postgresFunctions[type?.value] || []).map(
|
||||||
|
(functionName: string) => ({
|
||||||
|
label: functionName,
|
||||||
|
value: functionName,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const [inputValue, setInputValue] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(type?.label ?? '');
|
||||||
|
}, [type?.label]);
|
||||||
|
|
||||||
|
// react-hook-form's isDirty gets true even if an input field is focused, then
|
||||||
|
// immediately unfocused - we can't rely on that information
|
||||||
|
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyStateChange(isDirty, location);
|
||||||
|
}, [isDirty, location, onDirtyStateChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
onSubmit={handleExternalSubmit}
|
||||||
|
className="flex flex-auto flex-col content-between overflow-hidden border-t-1"
|
||||||
|
>
|
||||||
|
<div className="flex-auto overflow-y-auto">
|
||||||
|
<section className="grid grid-cols-8 px-6 py-3">
|
||||||
|
<Input
|
||||||
|
{...register('name', {
|
||||||
|
onChange: (event) => {
|
||||||
|
if (foreignKeyRelation) {
|
||||||
|
setValue(`foreignKeyRelation`, {
|
||||||
|
...foreignKeyRelation,
|
||||||
|
columnName: event.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
id="name"
|
||||||
|
fullWidth
|
||||||
|
label="Name"
|
||||||
|
helperText={errors.name?.message}
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={Boolean(errors.name)}
|
||||||
|
variant="inline"
|
||||||
|
className="col-span-8 py-3"
|
||||||
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<ControlledAutocomplete
|
||||||
|
id="type"
|
||||||
|
name="type"
|
||||||
|
control={control}
|
||||||
|
aria-label="Type"
|
||||||
|
fullWidth
|
||||||
|
options={postgresTypeGroups}
|
||||||
|
groupBy={(option) => option.group ?? ''}
|
||||||
|
error={Boolean(errors.type)}
|
||||||
|
placeholder="Select a column type"
|
||||||
|
label="Type"
|
||||||
|
hideEmptyHelperText
|
||||||
|
className="col-span-8 py-3"
|
||||||
|
variant="inline"
|
||||||
|
autoHighlight
|
||||||
|
clearOnBlur
|
||||||
|
showCustomOption="first"
|
||||||
|
filterOptions={defaultFilterGroupedOptions}
|
||||||
|
freeSolo
|
||||||
|
inputValue={inputValue}
|
||||||
|
onInputChange={(_event, value) => {
|
||||||
|
// Keep the list scrolled to the top while searching
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const listbox = document.querySelector('#type-listbox');
|
||||||
|
if (listbox) {
|
||||||
|
listbox.scrollTop = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setInputValue(value);
|
||||||
|
}}
|
||||||
|
renderOption={(props, { label, value, custom }) => {
|
||||||
|
if (custom) {
|
||||||
|
return (
|
||||||
|
<OptionBase {...props}>
|
||||||
|
<span>Use type: "{value}"</span>
|
||||||
|
</OptionBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<OptionBase {...props}>
|
||||||
|
<div className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5">
|
||||||
|
<span>{label}</span>
|
||||||
|
|
||||||
|
<InlineCode>{value}</InlineCode>
|
||||||
|
</div>
|
||||||
|
</OptionBase>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
setDefaultValueInputText('');
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
!identityTypes.includes(value?.value as ColumnType)
|
||||||
|
) {
|
||||||
|
setValue('isIdentity', false);
|
||||||
|
setValue('defaultValue', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{identityTypes.includes(type?.value) && (
|
||||||
|
<ControlledCheckbox
|
||||||
|
name="isIdentity"
|
||||||
|
label={
|
||||||
|
<span className="inline-grid grid-flow-row">
|
||||||
|
<span>Identity</span>
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Attach an implicit sequence to the column and make it
|
||||||
|
non-nullable
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
className="col-span-8 m-0 w-full py-3 sm:col-span-6 sm:col-start-3 sm:ml-1"
|
||||||
|
onChange={(_event, checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setDefaultValueInputText('');
|
||||||
|
setValue('defaultValue', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Box
|
||||||
|
component="section"
|
||||||
|
className="grid grid-cols-8 border-t-1 px-6 py-3"
|
||||||
|
>
|
||||||
|
<ControlledAutocomplete
|
||||||
|
id="defaultValue"
|
||||||
|
name="defaultValue"
|
||||||
|
control={control}
|
||||||
|
fullWidth
|
||||||
|
freeSolo
|
||||||
|
placeholder="NULL"
|
||||||
|
label="Default Value"
|
||||||
|
inputValue={isIdentity ? '' : defaultValueInputText}
|
||||||
|
onInputChange={(_event, value) => setDefaultValueInputText(value)}
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (
|
||||||
|
event.target instanceof HTMLInputElement &&
|
||||||
|
!event.target.value
|
||||||
|
) {
|
||||||
|
setValue('defaultValue', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
helperText={
|
||||||
|
errors.defaultValue?.message ||
|
||||||
|
'Can either be a literal or a function'
|
||||||
|
}
|
||||||
|
onChange={(_event, value) => {
|
||||||
|
if (typeof value !== 'string' && !Array.isArray(value)) {
|
||||||
|
setDefaultValueInputText(value?.value || '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoSelect={(filteredOptions) =>
|
||||||
|
filteredOptions?.length === 0 && defaultValueInputText.length > 0
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
paper: {
|
||||||
|
className: clsx(availableFunctions.length === 0 && 'hidden'),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
error={Boolean(errors.defaultValue)}
|
||||||
|
hideEmptyHelperText
|
||||||
|
autoHighlight
|
||||||
|
className="col-span-8 py-3"
|
||||||
|
variant="inline"
|
||||||
|
options={availableFunctions}
|
||||||
|
disabled={isIdentity}
|
||||||
|
showCustomOption="always"
|
||||||
|
customOptionLabel={(optionLabel) =>
|
||||||
|
`Use "${optionLabel}" as a literal`
|
||||||
|
}
|
||||||
|
noOptionsText="Enter a custom default value"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ControlledCheckbox
|
||||||
|
className="col-span-8 m-0 w-full py-3 sm:col-span-6 sm:col-start-3 sm:ml-1"
|
||||||
|
name="isNullable"
|
||||||
|
label={
|
||||||
|
<span className="inline-grid grid-flow-row">
|
||||||
|
<span>Nullable</span>
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Allow the column to assume a NULL value if no value is
|
||||||
|
provided
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
disabled={isIdentity}
|
||||||
|
uncheckWhenDisabled
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ControlledCheckbox
|
||||||
|
className="col-span-8 m-0 w-full py-3 sm:col-span-6 sm:col-start-3 sm:ml-1"
|
||||||
|
name="isUnique"
|
||||||
|
label={
|
||||||
|
<span className="inline-grid grid-flow-row">
|
||||||
|
<span>Unique</span>
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
className="text-xs font-normal"
|
||||||
|
color="secondary"
|
||||||
|
>
|
||||||
|
Enforce values in the column to be unique across rows
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
disabled={isIdentity}
|
||||||
|
uncheckWhenDisabled
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ForeignKeyEditor />
|
||||||
|
|
||||||
|
<Input
|
||||||
|
{...register('comment')}
|
||||||
|
id="comment"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
rows={3}
|
||||||
|
label="Comment"
|
||||||
|
helperText={errors.comment?.message}
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={Boolean(errors.comment)}
|
||||||
|
variant="inline"
|
||||||
|
className="col-span-8 py-3"
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 p-2">
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
onClick={onCancel}
|
||||||
|
tabIndex={isDirty ? -1 : 0}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
className="justify-self-end"
|
||||||
|
>
|
||||||
|
{submitButtonText}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||||
|
import { LinkIcon } from '@/components/ui/v2/icons/LinkIcon';
|
||||||
|
import { InputLabel } from '@/components/ui/v2/InputLabel';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { CreateForeignKeyForm } from '@/features/orgs/projects/database/dataGrid/components/CreateForeignKeyForm';
|
||||||
|
import { EditForeignKeyForm } from '@/features/orgs/projects/database/dataGrid/components/EditForeignKeyForm';
|
||||||
|
import type { DatabaseColumn } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import type { ForwardedRef } from 'react';
|
||||||
|
import { forwardRef, useRef } from 'react';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
|
||||||
|
interface ForeignKeyEditorInputProps {
|
||||||
|
/**
|
||||||
|
* Function to be called when the user finishes creating a foreign key.
|
||||||
|
*/
|
||||||
|
onCreateSubmit: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Function to be called when the user finishes editing a foreign key.
|
||||||
|
*/
|
||||||
|
onEditSubmit: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ForeignKeyEditorInput = forwardRef(
|
||||||
|
(
|
||||||
|
{ onCreateSubmit, onEditSubmit }: ForeignKeyEditorInputProps,
|
||||||
|
ref: ForwardedRef<HTMLButtonElement>,
|
||||||
|
) => {
|
||||||
|
const { openDialog } = useDialog();
|
||||||
|
const { setValue } = useFormContext();
|
||||||
|
const column = useWatch() as DatabaseColumn;
|
||||||
|
const { foreignKeyRelation } = column;
|
||||||
|
|
||||||
|
if (!column.foreignKeyRelation) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
className="py-1"
|
||||||
|
disabled={!column.name || !column.type}
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => {
|
||||||
|
openDialog({
|
||||||
|
title: (
|
||||||
|
<span className="grid grid-flow-row">
|
||||||
|
<span>Add a Foreign Key Relation</span>
|
||||||
|
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Foreign keys help ensure the referential integrity of your
|
||||||
|
data.
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
component: (
|
||||||
|
<CreateForeignKeyForm
|
||||||
|
selectedColumn={column.name}
|
||||||
|
availableColumns={[column]}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
setValue('foreignKeyRelation', values);
|
||||||
|
onCreateSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Add Foreign Key
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-flow-col items-center justify-between justify-items-start gap-2 rounded-sm+ px-2">
|
||||||
|
<div className="grid grid-flow-col items-center gap-2">
|
||||||
|
<LinkIcon className="h-4 w-4" />
|
||||||
|
|
||||||
|
<Text className="grid grid-flow-col items-center gap-1.5 truncate font-medium">
|
||||||
|
<span className="truncate">{foreignKeyRelation?.columnName}</span>
|
||||||
|
<ArrowRightIcon />
|
||||||
|
<span className="truncate">
|
||||||
|
{foreignKeyRelation?.referencedSchema}.
|
||||||
|
{foreignKeyRelation?.referencedTable}.
|
||||||
|
{foreignKeyRelation?.referencedColumn}
|
||||||
|
</span>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-col">
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
onClick={() => {
|
||||||
|
openDialog({
|
||||||
|
title: 'Edit Foreign Key Relation',
|
||||||
|
component: (
|
||||||
|
<EditForeignKeyForm
|
||||||
|
foreignKeyRelation={foreignKeyRelation!}
|
||||||
|
selectedColumn={column.name}
|
||||||
|
availableColumns={[column]}
|
||||||
|
onSubmit={(values) => {
|
||||||
|
setValue('foreignKeyRelation', values);
|
||||||
|
onEditSubmit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
variant="borderless"
|
||||||
|
className="min-w-[initial] px-2 py-1"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => setValue('foreignKeyRelation', null)}
|
||||||
|
variant="borderless"
|
||||||
|
className="min-w-[initial] px-2 py-1"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
ForeignKeyEditorInput.displayName = 'NhostForeignKeyEditorInput';
|
||||||
|
|
||||||
|
export default function ForeignKeyEditor() {
|
||||||
|
const buttonRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="col-span-8 grid grid-cols-8 items-center justify-start gap-x-4 gap-y-2">
|
||||||
|
<InputLabel className="col-span-8 sm:col-span-2">Foreign Key</InputLabel>
|
||||||
|
|
||||||
|
<Box className="col-span-8 rounded-sm+ border-1 px-1 py-[3px] sm:col-span-6">
|
||||||
|
<ForeignKeyEditorInput
|
||||||
|
ref={buttonRef}
|
||||||
|
onEditSubmit={() =>
|
||||||
|
requestAnimationFrame(() => buttonRef.current?.focus())
|
||||||
|
}
|
||||||
|
onCreateSubmit={() =>
|
||||||
|
requestAnimationFrame(() => buttonRef.current?.focus())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './BaseColumnForm';
|
||||||
|
export { default as BaseColumnForm } from './BaseColumnForm';
|
||||||
@@ -4,6 +4,7 @@ import { Box } from '@/components/ui/v2/Box';
|
|||||||
import { Button } from '@/components/ui/v2/Button';
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { baseColumnValidationSchema } from '@/features/orgs/projects/database/dataGrid/components/BaseColumnForm';
|
||||||
import type {
|
import type {
|
||||||
DatabaseTable,
|
DatabaseTable,
|
||||||
ForeignKeyRelation,
|
ForeignKeyRelation,
|
||||||
@@ -50,23 +51,6 @@ export interface BaseTableFormProps extends DialogFormProps {
|
|||||||
submitButtonText?: string;
|
submitButtonText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const baseColumnValidationSchema = Yup.object().shape({
|
|
||||||
name: Yup.string()
|
|
||||||
.required('This field is required.')
|
|
||||||
.matches(
|
|
||||||
/^([A-Za-z]|_)+/i,
|
|
||||||
'Column name must start with a letter or underscore.',
|
|
||||||
)
|
|
||||||
.matches(
|
|
||||||
/^\w+$/i,
|
|
||||||
'Column name must contain only letters, numbers, or underscores.',
|
|
||||||
),
|
|
||||||
type: Yup.object()
|
|
||||||
.shape({ value: Yup.string().required() })
|
|
||||||
.required('This field is required.')
|
|
||||||
.nullable(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const baseTableValidationSchema = Yup.object({
|
export const baseTableValidationSchema = Yup.object({
|
||||||
name: Yup.string()
|
name: Yup.string()
|
||||||
.required('This field is required.')
|
.required('This field is required.')
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
||||||
import { InlineCode } from '@/components/ui/v3/inline-code';
|
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||||
|
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
||||||
import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath';
|
import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath';
|
||||||
import { DataBrowserEmptyState } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState';
|
import { DataBrowserEmptyState } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState';
|
||||||
import { DataBrowserGridControls } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls';
|
import { DataBrowserGridControls } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls';
|
||||||
|
import { useDeleteColumnWithToastMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useDeleteColumnMutation';
|
||||||
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
||||||
import type { UpdateRecordVariables } from '@/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation';
|
import type { UpdateRecordVariables } from '@/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation';
|
||||||
import { useUpdateRecordWithToastMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation';
|
import { useUpdateRecordWithToastMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation';
|
||||||
@@ -19,6 +21,8 @@ import {
|
|||||||
POSTGRESQL_INTEGER_TYPES,
|
POSTGRESQL_INTEGER_TYPES,
|
||||||
POSTGRESQL_JSON_TYPES,
|
POSTGRESQL_JSON_TYPES,
|
||||||
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||||
|
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||||
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||||
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
|
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
|
||||||
@@ -27,11 +31,18 @@ import { DataGridDecimalCell } from '@/features/orgs/projects/storage/dataGrid/c
|
|||||||
import { DataGridIntegerCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridIntegerCell';
|
import { DataGridIntegerCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridIntegerCell';
|
||||||
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { KeyRound } from 'lucide-react';
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const EditColumnForm = dynamic(
|
||||||
|
() =>
|
||||||
|
import(
|
||||||
|
'@/features/orgs/projects/database/dataGrid/components/EditColumnForm/EditColumnForm'
|
||||||
|
),
|
||||||
|
{ ssr: false, loading: () => <FormActivityIndicator /> },
|
||||||
|
);
|
||||||
|
|
||||||
const CreateRecordForm = dynamic(
|
const CreateRecordForm = dynamic(
|
||||||
() =>
|
() =>
|
||||||
import(
|
import(
|
||||||
@@ -52,7 +63,7 @@ export function createDataGridColumn(
|
|||||||
const defaultColumnConfiguration = {
|
const defaultColumnConfiguration = {
|
||||||
Header: () => (
|
Header: () => (
|
||||||
<div className="grid grid-flow-col items-center justify-start gap-1 font-normal">
|
<div className="grid grid-flow-col items-center justify-start gap-1 font-normal">
|
||||||
{column.is_primary && <KeyRound width={14} height={14} />}
|
{column.is_primary && <KeyIcon className="text-sm" />}
|
||||||
|
|
||||||
<span className="truncate font-bold" title={column.column_name}>
|
<span className="truncate font-bold" title={column.column_name}>
|
||||||
{column.column_name}
|
{column.column_name}
|
||||||
@@ -156,15 +167,23 @@ export default function DataBrowserGrid({
|
|||||||
...router
|
...router
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
const currentTablePath = useTablePath();
|
const currentTablePath = useTablePath();
|
||||||
|
const isSchemaEditable = !isSchemaLocked(schemaSlug as string);
|
||||||
|
const { openDrawer, openAlertDialog } = useDialog();
|
||||||
|
|
||||||
const { openDrawer } = useDialog();
|
const { project } = useProject();
|
||||||
|
const isGitHubConnected = !!project?.githubRepository;
|
||||||
|
|
||||||
const limit = 25;
|
const limit = 25;
|
||||||
const [currentOffset, setCurrentOffset] = useState<number>(
|
const [currentOffset, setCurrentOffset] = useState<number>(
|
||||||
parseInt(page as string, 10) - 1 || 0,
|
parseInt(page as string, 10) - 1 || 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [removableColumnId, setRemovableColumnId] = useState<string>();
|
||||||
|
const [optimisticlyRemovedColumnId, setOptimisticlyRemovedColumnId] =
|
||||||
|
useState<string>();
|
||||||
|
|
||||||
const { mutateAsync: updateRow } = useUpdateRecordWithToastMutation();
|
const { mutateAsync: updateRow } = useUpdateRecordWithToastMutation();
|
||||||
|
const { mutateAsync: deleteColumn } = useDeleteColumnWithToastMutation();
|
||||||
|
|
||||||
const { data, status, error, refetch } = useTableQuery(
|
const { data, status, error, refetch } = useTableQuery(
|
||||||
[currentTablePath, limit, currentOffset, sortBy],
|
[currentTablePath, limit, currentOffset, sortBy],
|
||||||
@@ -252,16 +271,26 @@ export default function DataBrowserGrid({
|
|||||||
|
|
||||||
const memoizedColumns = useMemo(
|
const memoizedColumns = useMemo(
|
||||||
() =>
|
() =>
|
||||||
columns.map((column) => ({
|
columns
|
||||||
...createDataGridColumn(column, true),
|
.map((column) => ({
|
||||||
onCellEdit: async (variables: UpdateRecordVariables) => {
|
...createDataGridColumn(column, true),
|
||||||
const result = await updateRow(variables);
|
onCellEdit: async (variables: UpdateRecordVariables) => {
|
||||||
await queryClient.invalidateQueries([currentTablePath]);
|
const result = await updateRow(variables);
|
||||||
|
await queryClient.invalidateQueries([currentTablePath]);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
},
|
},
|
||||||
})),
|
isDisabled: removableColumnId === column.column_name,
|
||||||
[columns, currentTablePath, queryClient, updateRow],
|
}))
|
||||||
|
.filter((column) => column.id !== optimisticlyRemovedColumnId),
|
||||||
|
[
|
||||||
|
columns,
|
||||||
|
currentTablePath,
|
||||||
|
optimisticlyRemovedColumnId,
|
||||||
|
queryClient,
|
||||||
|
removableColumnId,
|
||||||
|
updateRow,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const memoizedData = useMemo(() => rows, [rows]);
|
const memoizedData = useMemo(() => rows, [rows]);
|
||||||
@@ -279,6 +308,58 @@ export default function DataBrowserGrid({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleEditColumnClick(
|
||||||
|
column: DataBrowserGridColumn<NormalizedQueryDataRow>,
|
||||||
|
) {
|
||||||
|
openDrawer({
|
||||||
|
title: 'Edit Column',
|
||||||
|
component: (
|
||||||
|
<EditColumnForm
|
||||||
|
column={column}
|
||||||
|
onSubmit={() => queryClient.refetchQueries([currentTablePath])}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleColumnDeleteConfirmation(
|
||||||
|
column: DataBrowserGridColumn<NormalizedQueryDataRow>,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// We are greying out and disabling it in the grid
|
||||||
|
setRemovableColumnId(column.id);
|
||||||
|
await deleteColumn({ column });
|
||||||
|
|
||||||
|
// Note: At this point we can optimistically assume that the column was
|
||||||
|
// removed, so we can improve the UX by removing it from the grid right
|
||||||
|
// away, without waiting for the refetch to succeed.
|
||||||
|
setOptimisticlyRemovedColumnId(column.id);
|
||||||
|
await queryClient.refetchQueries([currentTablePath]);
|
||||||
|
} finally {
|
||||||
|
setRemovableColumnId(undefined);
|
||||||
|
setOptimisticlyRemovedColumnId(undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleColumnRemoveClick(
|
||||||
|
column: DataBrowserGridColumn<NormalizedQueryDataRow>,
|
||||||
|
) {
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Delete column',
|
||||||
|
payload: (
|
||||||
|
<span>
|
||||||
|
Are you sure you want to delete the{' '}
|
||||||
|
<strong className="break-all">{column.id}</strong> column?
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
primaryButtonText: 'Delete',
|
||||||
|
primaryButtonColor: 'error',
|
||||||
|
onPrimaryAction: () => handleColumnDeleteConfirmation(column),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (metadata?.schemaNotFound) {
|
if (metadata?.schemaNotFound) {
|
||||||
return (
|
return (
|
||||||
<DataBrowserEmptyState
|
<DataBrowserEmptyState
|
||||||
@@ -333,6 +414,8 @@ export default function DataBrowserGrid({
|
|||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
className="pb-17 sm:pb-0"
|
className="pb-17 sm:pb-0"
|
||||||
onInsertRow={handleInsertRowClick}
|
onInsertRow={handleInsertRowClick}
|
||||||
|
onEditColumn={isSchemaEditable ? handleEditColumnClick : undefined}
|
||||||
|
onRemoveColumn={isSchemaEditable ? handleColumnRemoveClick : undefined}
|
||||||
options={{
|
options={{
|
||||||
manualSortBy: true,
|
manualSortBy: true,
|
||||||
disableMultiSort: true,
|
disableMultiSort: true,
|
||||||
@@ -340,6 +423,12 @@ export default function DataBrowserGrid({
|
|||||||
autoResetSelectedRows: false,
|
autoResetSelectedRows: false,
|
||||||
autoResetResize: false,
|
autoResetResize: false,
|
||||||
}}
|
}}
|
||||||
|
headerProps={{
|
||||||
|
componentsProps: {
|
||||||
|
editActionProps: { disabled: isGitHubConnected },
|
||||||
|
deleteActionProps: { disabled: isGitHubConnected },
|
||||||
|
},
|
||||||
|
}}
|
||||||
controls={
|
controls={
|
||||||
<DataBrowserGridControls
|
<DataBrowserGridControls
|
||||||
onInsertRowClick={handleInsertRowClick}
|
onInsertRowClick={handleInsertRowClick}
|
||||||
|
|||||||
@@ -603,7 +603,7 @@ export default function DataBrowserSidebar({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
className="absolute bottom-4 left-8 z-[38] h-11 w-11 rounded-full md:hidden"
|
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
aria-label="Toggle sidebar"
|
aria-label="Toggle sidebar"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { Alert } from '@/components/ui/v2/Alert';
|
||||||
|
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import type {
|
||||||
|
BaseColumnFormProps,
|
||||||
|
BaseColumnFormValues,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/components/BaseColumnForm';
|
||||||
|
import {
|
||||||
|
BaseColumnForm,
|
||||||
|
baseColumnValidationSchema,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/components/BaseColumnForm';
|
||||||
|
import { useTrackForeignKeyRelationsMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useTrackForeignKeyRelationsMutation';
|
||||||
|
import { useUpdateColumnMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useUpdateColumnMutation';
|
||||||
|
import type {
|
||||||
|
ColumnType,
|
||||||
|
DataBrowserGridColumn,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import { convertDataBrowserGridColumnToDatabaseColumn } from '@/features/orgs/projects/database/dataGrid/utils/convertDataBrowserGridColumnToDatabaseColumn';
|
||||||
|
import { isNotEmptyValue } from '@/lib/utils';
|
||||||
|
import { triggerToast } from '@/utils/toast';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import type * as Yup from 'yup';
|
||||||
|
|
||||||
|
export interface EditColumnFormProps
|
||||||
|
extends Pick<BaseColumnFormProps, 'onCancel' | 'location'> {
|
||||||
|
/**
|
||||||
|
* Column to be edited.
|
||||||
|
*/
|
||||||
|
column: DataBrowserGridColumn;
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditColumnForm({
|
||||||
|
column: originalColumn,
|
||||||
|
onSubmit,
|
||||||
|
...props
|
||||||
|
}: EditColumnFormProps) {
|
||||||
|
const {
|
||||||
|
query: { schemaSlug, tableSlug },
|
||||||
|
} = useRouter();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: updateColumn,
|
||||||
|
error: updateColumnError,
|
||||||
|
reset: resetUpdateColumnError,
|
||||||
|
} = useUpdateColumnMutation();
|
||||||
|
|
||||||
|
const {
|
||||||
|
mutateAsync: trackForeignKeyRelation,
|
||||||
|
error: foreignKeyError,
|
||||||
|
reset: resetForeignKeyError,
|
||||||
|
} = useTrackForeignKeyRelationsMutation();
|
||||||
|
|
||||||
|
const error = updateColumnError || foreignKeyError;
|
||||||
|
|
||||||
|
function resetError() {
|
||||||
|
resetUpdateColumnError();
|
||||||
|
resetForeignKeyError();
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultValue: AutocompleteOption = {
|
||||||
|
value: originalColumn.defaultValue,
|
||||||
|
label: originalColumn.defaultValue,
|
||||||
|
custom: originalColumn.isDefaultValueCustom,
|
||||||
|
};
|
||||||
|
|
||||||
|
const columnValues: BaseColumnFormValues = {
|
||||||
|
name: originalColumn.id,
|
||||||
|
type: {
|
||||||
|
value: originalColumn.specificType as ColumnType,
|
||||||
|
label: originalColumn.specificType as ColumnType,
|
||||||
|
},
|
||||||
|
defaultValue,
|
||||||
|
isNullable: originalColumn.isNullable || false,
|
||||||
|
isUnique: originalColumn.isUnique || false,
|
||||||
|
isIdentity: originalColumn.isIdentity || false,
|
||||||
|
foreignKeyRelation: originalColumn.foreignKeyRelation || null,
|
||||||
|
comment: originalColumn.comment || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<
|
||||||
|
BaseColumnFormValues | Yup.InferType<typeof baseColumnValidationSchema>
|
||||||
|
>({
|
||||||
|
defaultValues: columnValues,
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(baseColumnValidationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleSubmit(values: BaseColumnFormValues) {
|
||||||
|
try {
|
||||||
|
await updateColumn({
|
||||||
|
originalColumn:
|
||||||
|
convertDataBrowserGridColumnToDatabaseColumn(originalColumn),
|
||||||
|
column: values,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isNotEmptyValue(values.foreignKeyRelation)) {
|
||||||
|
await trackForeignKeyRelation({
|
||||||
|
foreignKeyRelations: [values.foreignKeyRelation],
|
||||||
|
schema: schemaSlug as string,
|
||||||
|
table: tableSlug as string,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onSubmit) {
|
||||||
|
await onSubmit();
|
||||||
|
}
|
||||||
|
|
||||||
|
triggerToast('The column has been updated successfully.');
|
||||||
|
} catch {
|
||||||
|
// This error is handled by the useUpdateColumnMutation hook.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
{error && error instanceof Error ? (
|
||||||
|
<div className="-mt-3 mb-4 px-6">
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<span className="text-left">
|
||||||
|
<strong>Error:</strong> {error.message}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
onClick={resetError}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<BaseColumnForm
|
||||||
|
submitButtonText="Save"
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EditColumnForm';
|
||||||
|
export { default as EditColumnForm } from './EditColumnForm';
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type {
|
||||||
|
AffectedRowsResult,
|
||||||
|
DataBrowserGridColumn,
|
||||||
|
MutationOrQueryBaseOptions,
|
||||||
|
QueryError,
|
||||||
|
QueryResult,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import { getPreparedHasuraQuery } from '@/features/orgs/projects/database/dataGrid/utils/hasuraQueryHelpers';
|
||||||
|
import { normalizeQueryError } from '@/features/orgs/projects/database/dataGrid/utils/normalizeQueryError';
|
||||||
|
|
||||||
|
export interface DeleteColumnVariables {
|
||||||
|
/**
|
||||||
|
* Column to remove from the table.
|
||||||
|
*/
|
||||||
|
column: DataBrowserGridColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteColumnOptions extends MutationOrQueryBaseOptions {}
|
||||||
|
|
||||||
|
export default async function deleteColumn({
|
||||||
|
dataSource,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
appUrl,
|
||||||
|
adminSecret,
|
||||||
|
column,
|
||||||
|
}: DeleteColumnOptions & DeleteColumnVariables) {
|
||||||
|
const response = await fetch(`${appUrl}/v2/query`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-hasura-admin-secret': adminSecret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
args: [
|
||||||
|
getPreparedHasuraQuery(
|
||||||
|
dataSource,
|
||||||
|
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
column.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
type: 'bulk',
|
||||||
|
version: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
|
await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedError = normalizeQueryError(responseData);
|
||||||
|
|
||||||
|
throw new Error(normalizedError);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { prepareCreateColumnQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useCreateColumnMutation';
|
||||||
|
import type {
|
||||||
|
AffectedRowsResult,
|
||||||
|
ColumnType,
|
||||||
|
DataBrowserGridColumn,
|
||||||
|
MutationOrQueryBaseOptions,
|
||||||
|
QueryError,
|
||||||
|
QueryResult,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import { getPreparedHasuraQuery } from '@/features/orgs/projects/database/dataGrid/utils/hasuraQueryHelpers';
|
||||||
|
import { normalizeQueryError } from '@/features/orgs/projects/database/dataGrid/utils/normalizeQueryError';
|
||||||
|
import { getHasuraMigrationsApiUrl } from '@/utils/env';
|
||||||
|
|
||||||
|
export interface DeleteColumnMigrationVariables {
|
||||||
|
/**
|
||||||
|
* Column to remove from the table.
|
||||||
|
*/
|
||||||
|
column: DataBrowserGridColumn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteColumnMigrationOptions
|
||||||
|
extends MutationOrQueryBaseOptions {}
|
||||||
|
|
||||||
|
export default async function deleteColumnMigration({
|
||||||
|
dataSource,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
adminSecret,
|
||||||
|
column,
|
||||||
|
}: DeleteColumnMigrationOptions & DeleteColumnMigrationVariables) {
|
||||||
|
const recreateColumnArgs = prepareCreateColumnQuery({
|
||||||
|
dataSource,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
column: {
|
||||||
|
...column,
|
||||||
|
name: column.id,
|
||||||
|
type: {
|
||||||
|
value: column.specificType as ColumnType,
|
||||||
|
label: column.specificType as ColumnType,
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
value: column.defaultValue,
|
||||||
|
label: column.defaultValue,
|
||||||
|
custom: column.isDefaultValueCustom,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`${getHasuraMigrationsApiUrl()}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-hasura-admin-secret': adminSecret,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
dataSource,
|
||||||
|
skip_execution: false,
|
||||||
|
name: `alter_table_${schema}_${table}_drop_column_${column.id}`,
|
||||||
|
down: recreateColumnArgs,
|
||||||
|
up: [
|
||||||
|
getPreparedHasuraQuery(
|
||||||
|
dataSource,
|
||||||
|
'ALTER TABLE %I.%I DROP COLUMN IF EXISTS %I CASCADE',
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
column.id,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseData: [AffectedRowsResult, QueryResult<string[]>] | QueryError =
|
||||||
|
await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedError = normalizeQueryError(responseData);
|
||||||
|
|
||||||
|
throw new Error(normalizedError);
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './deleteColumn';
|
||||||
|
export * from './useDeleteColumnMutation';
|
||||||
|
export { default as useDeleteColumnMutation } from './useDeleteColumnMutation';
|
||||||
|
export { default as useDeleteColumnWithToastMutation } from './useDeleteColumnWithToastMutation';
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { getHasuraAdminSecret } from '@/utils/env';
|
||||||
|
import type { MutationOptions } from '@tanstack/react-query';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import type {
|
||||||
|
DeleteColumnOptions,
|
||||||
|
DeleteColumnVariables,
|
||||||
|
} from './deleteColumn';
|
||||||
|
import deleteColumn from './deleteColumn';
|
||||||
|
import deleteColumnMigration from './deleteColumnMigration';
|
||||||
|
|
||||||
|
export interface UseDeleteColumnMutationOptions
|
||||||
|
extends Partial<DeleteColumnOptions> {
|
||||||
|
/**
|
||||||
|
* Props passed to the underlying mutation hook.
|
||||||
|
*/
|
||||||
|
mutationOptions?: MutationOptions<void, unknown, DeleteColumnVariables>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook is a wrapper around a fetch call that deletes one or more columns
|
||||||
|
* from the table.
|
||||||
|
*
|
||||||
|
* @param options - Options to use for the mutation.
|
||||||
|
* @returns The result of the mutation.
|
||||||
|
*/
|
||||||
|
export default function useDeleteColumnMutation({
|
||||||
|
dataSource: customDataSource,
|
||||||
|
schema: customSchema,
|
||||||
|
table: customTable,
|
||||||
|
appUrl: customAppUrl,
|
||||||
|
adminSecret: customAdminSecret,
|
||||||
|
mutationOptions,
|
||||||
|
}: UseDeleteColumnMutationOptions = {}) {
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const {
|
||||||
|
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||||
|
} = useRouter();
|
||||||
|
const { project } = useProject();
|
||||||
|
const mutationFn = isPlatform ? deleteColumn : deleteColumnMigration;
|
||||||
|
|
||||||
|
const mutation = useMutation((variables) => {
|
||||||
|
const appUrl = generateAppServiceUrl(
|
||||||
|
project!.subdomain,
|
||||||
|
project!.region,
|
||||||
|
'hasura',
|
||||||
|
);
|
||||||
|
return mutationFn({
|
||||||
|
...variables,
|
||||||
|
appUrl: customAppUrl || appUrl,
|
||||||
|
adminSecret:
|
||||||
|
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||||
|
? getHasuraAdminSecret()
|
||||||
|
: customAdminSecret || project?.config?.hasura.adminSecret!,
|
||||||
|
dataSource: customDataSource || (dataSourceSlug as string),
|
||||||
|
schema: customSchema || (schemaSlug as string),
|
||||||
|
table: customTable || (tableSlug as string),
|
||||||
|
});
|
||||||
|
}, mutationOptions);
|
||||||
|
|
||||||
|
return mutation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { UseDeleteColumnMutationOptions } from '@/features/orgs/projects/database/dataGrid/hooks/useDeleteColumnMutation';
|
||||||
|
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import useDeleteColumnMutation from './useDeleteColumnMutation';
|
||||||
|
|
||||||
|
export interface UseDeleteColumnWithToastMutationOptions
|
||||||
|
extends UseDeleteColumnMutationOptions {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This hook is a wrapper around a fetch call that deletes one or more columns
|
||||||
|
* from the table. It also shows toast messages based on the result of the
|
||||||
|
* mutation.
|
||||||
|
*
|
||||||
|
* @param options - Options to use for the mutation.
|
||||||
|
* @returns The result of the mutation.
|
||||||
|
*/
|
||||||
|
export default function useDeleteColumnWithToastMutation(
|
||||||
|
options: UseDeleteColumnWithToastMutationOptions = {},
|
||||||
|
) {
|
||||||
|
const [toastId, setToastId] = useState<string>();
|
||||||
|
const { status, error, ...rest } = useDeleteColumnMutation(options);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status === 'loading') {
|
||||||
|
const loadingToastId = showLoadingToast('Deleting column...', {
|
||||||
|
id: 'data-browser-column-delete',
|
||||||
|
});
|
||||||
|
|
||||||
|
setToastId(loadingToastId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'error' && toastId) {
|
||||||
|
toast.remove(toastId);
|
||||||
|
|
||||||
|
if (error && error instanceof Error) {
|
||||||
|
triggerToast(
|
||||||
|
error.message || 'An error occurred while deleting the column.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
triggerToast('An error occurred while deleting the column.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'success' && toastId) {
|
||||||
|
toast.remove(toastId);
|
||||||
|
|
||||||
|
triggerToast('The column has been deleted successfully.');
|
||||||
|
}
|
||||||
|
}, [status, error, toastId]);
|
||||||
|
|
||||||
|
return { status, ...rest };
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { DatabaseColumn } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import convertDataBrowserGridColumnToDatabaseColumn from './convertDataBrowserGridColumnToDatabaseColumn';
|
||||||
|
|
||||||
|
test('should convert a data browser column to a database column', () => {
|
||||||
|
const column = convertDataBrowserGridColumnToDatabaseColumn({
|
||||||
|
id: 'id',
|
||||||
|
type: 'text',
|
||||||
|
specificType: 'uuid',
|
||||||
|
isIdentity: false,
|
||||||
|
isUnique: true,
|
||||||
|
isPrimary: true,
|
||||||
|
isNullable: false,
|
||||||
|
defaultValue: {
|
||||||
|
value: 'gen_random_uuid()',
|
||||||
|
label: 'gen_random_uuid()',
|
||||||
|
},
|
||||||
|
isDefaultValueCustom: true,
|
||||||
|
comment: 'Lorem ipsum',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(column).toMatchObject<DatabaseColumn>({
|
||||||
|
id: 'id',
|
||||||
|
name: 'id',
|
||||||
|
isIdentity: false,
|
||||||
|
isUnique: true,
|
||||||
|
isPrimary: true,
|
||||||
|
isNullable: false,
|
||||||
|
type: {
|
||||||
|
value: 'uuid',
|
||||||
|
label: 'uuid',
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
value: 'gen_random_uuid()',
|
||||||
|
label: 'gen_random_uuid()',
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
foreignKeyRelation: null,
|
||||||
|
comment: 'Lorem ipsum',
|
||||||
|
primaryConstraints: [],
|
||||||
|
uniqueConstraints: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should convert a string based default value to an autocomplete option', () => {
|
||||||
|
const column = convertDataBrowserGridColumnToDatabaseColumn({
|
||||||
|
id: 'id',
|
||||||
|
type: 'number',
|
||||||
|
specificType: 'int4',
|
||||||
|
isIdentity: false,
|
||||||
|
isUnique: true,
|
||||||
|
isPrimary: true,
|
||||||
|
isNullable: false,
|
||||||
|
defaultValue: '0',
|
||||||
|
isDefaultValueCustom: true,
|
||||||
|
comment: 'Lorem ipsum',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(column).toMatchObject<DatabaseColumn>({
|
||||||
|
id: 'id',
|
||||||
|
name: 'id',
|
||||||
|
isIdentity: false,
|
||||||
|
isUnique: true,
|
||||||
|
isPrimary: true,
|
||||||
|
isNullable: false,
|
||||||
|
type: {
|
||||||
|
value: 'int4',
|
||||||
|
label: 'int4',
|
||||||
|
},
|
||||||
|
defaultValue: {
|
||||||
|
value: '0',
|
||||||
|
label: '0',
|
||||||
|
custom: true,
|
||||||
|
},
|
||||||
|
foreignKeyRelation: null,
|
||||||
|
comment: 'Lorem ipsum',
|
||||||
|
primaryConstraints: [],
|
||||||
|
uniqueConstraints: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
||||||
|
import type {
|
||||||
|
DatabaseColumn,
|
||||||
|
DataBrowserGridColumn,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a data browser grid column to a normalized database column.
|
||||||
|
*
|
||||||
|
* @param dataBrowserGridColumn - Data browser grid column.
|
||||||
|
* @returns Normalized database column.
|
||||||
|
*/
|
||||||
|
export default function convertDataBrowserGridColumnToDatabaseColumn(
|
||||||
|
dataBrowserGridColumn: Partial<DataBrowserGridColumn> &
|
||||||
|
Required<Pick<DataBrowserGridColumn, 'id'>>,
|
||||||
|
): DatabaseColumn {
|
||||||
|
let defaultValue: AutocompleteOption | null = null;
|
||||||
|
|
||||||
|
if (typeof dataBrowserGridColumn.defaultValue === 'string') {
|
||||||
|
defaultValue = {
|
||||||
|
value: dataBrowserGridColumn.defaultValue,
|
||||||
|
label: dataBrowserGridColumn.defaultValue,
|
||||||
|
custom: dataBrowserGridColumn.isDefaultValueCustom || false,
|
||||||
|
};
|
||||||
|
} else if (dataBrowserGridColumn.defaultValue) {
|
||||||
|
defaultValue = {
|
||||||
|
value: dataBrowserGridColumn.defaultValue.value,
|
||||||
|
label: dataBrowserGridColumn.defaultValue.label,
|
||||||
|
custom:
|
||||||
|
dataBrowserGridColumn.isDefaultValueCustom ||
|
||||||
|
dataBrowserGridColumn.defaultValue.custom ||
|
||||||
|
false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dataBrowserGridColumn.id,
|
||||||
|
name: dataBrowserGridColumn.id,
|
||||||
|
isIdentity: dataBrowserGridColumn.isIdentity || false,
|
||||||
|
isPrimary: dataBrowserGridColumn.isPrimary || false,
|
||||||
|
isUnique: dataBrowserGridColumn.isUnique || false,
|
||||||
|
isNullable: dataBrowserGridColumn.isNullable || false,
|
||||||
|
type: {
|
||||||
|
value: dataBrowserGridColumn.specificType!,
|
||||||
|
label: dataBrowserGridColumn.specificType!,
|
||||||
|
},
|
||||||
|
defaultValue,
|
||||||
|
foreignKeyRelation: dataBrowserGridColumn.foreignKeyRelation || null,
|
||||||
|
comment: dataBrowserGridColumn.comment || null,
|
||||||
|
primaryConstraints: dataBrowserGridColumn.primaryConstraints || [],
|
||||||
|
uniqueConstraints: dataBrowserGridColumn.uniqueConstraints || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as convertDataBrowserGridColumnToDatabaseColumn } from './convertDataBrowserGridColumnToDatabaseColumn';
|
||||||
@@ -26,10 +26,6 @@ const useNavTreeStateFromURL = (): TreeState => {
|
|||||||
const isSettingsPage = pathSegments.includes('settings');
|
const isSettingsPage = pathSegments.includes('settings');
|
||||||
const settingsPage = isSettingsPage ? pathSegments[6] || null : null;
|
const settingsPage = isSettingsPage ? pathSegments[6] || null : null;
|
||||||
|
|
||||||
const isGraphQLPage = pathSegments.includes('graphql');
|
|
||||||
const graphqlSubPage =
|
|
||||||
isGraphQLPage && pathSegments.length > 6 ? pathSegments[6] || null : null;
|
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (!orgSlug) {
|
if (!orgSlug) {
|
||||||
// If no orgSlug, return an empty state
|
// If no orgSlug, return an empty state
|
||||||
@@ -71,15 +67,6 @@ const useNavTreeStateFromURL = (): TreeState => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isGraphQLPage) {
|
|
||||||
expandedItems.push(`${orgSlug}-${appSubdomain}-graphql`);
|
|
||||||
if (!graphqlSubPage) {
|
|
||||||
focusedItem = `${orgSlug}-${appSubdomain}-graphql-playground`;
|
|
||||||
} else {
|
|
||||||
focusedItem = `${orgSlug}-${appSubdomain}-graphql-${graphqlSubPage}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { expandedItems, focusedItem };
|
return { expandedItems, focusedItem };
|
||||||
}, [
|
}, [
|
||||||
orgSlug,
|
orgSlug,
|
||||||
@@ -88,8 +75,6 @@ const useNavTreeStateFromURL = (): TreeState => {
|
|||||||
projectPage,
|
projectPage,
|
||||||
settingsPage,
|
settingsPage,
|
||||||
isSettingsPage,
|
isSettingsPage,
|
||||||
isGraphQLPage,
|
|
||||||
graphqlSubPage,
|
|
||||||
newProject,
|
newProject,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
import { HelperText } from '@/components/ui/v2/HelperText';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
|
||||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
|
||||||
import { Select } from '@/components/ui/v2/Select';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { inputBaseClasses } from '@mui/material';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export default function AdditionalHeadersEditor() {
|
|
||||||
const form = useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
const [headerTypes, setHeaderTypes] = useState<
|
|
||||||
Record<string, 'value' | 'value_from_env'>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
setValue,
|
|
||||||
formState: { errors },
|
|
||||||
watch,
|
|
||||||
} = form;
|
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({
|
|
||||||
name: 'definition.headers',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const currentHeaders = watch('definition.headers') || [];
|
|
||||||
const initialHeaderTypes: Record<string, 'value' | 'value_from_env'> = {};
|
|
||||||
|
|
||||||
fields.forEach((field, index) => {
|
|
||||||
const header = currentHeaders[index];
|
|
||||||
if (header) {
|
|
||||||
if (header.value_from_env && !header.value) {
|
|
||||||
initialHeaderTypes[field.id] = 'value_from_env';
|
|
||||||
} else {
|
|
||||||
initialHeaderTypes[field.id] = 'value';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
initialHeaderTypes[field.id] = 'value';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setHeaderTypes(initialHeaderTypes);
|
|
||||||
}, [fields, watch]);
|
|
||||||
|
|
||||||
const onChangeHeaderValueType = (
|
|
||||||
valueType: 'value' | 'value_from_env',
|
|
||||||
index: number,
|
|
||||||
fieldId: string,
|
|
||||||
) => {
|
|
||||||
setValue(`definition.headers.${index}.value`, '');
|
|
||||||
setValue(`definition.headers.${index}.value_from_env`, '');
|
|
||||||
|
|
||||||
setHeaderTypes((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[fieldId]: valueType,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getHeaderValueType = (fieldId: string): 'value' | 'value_from_env' =>
|
|
||||||
headerTypes[fieldId] || 'value';
|
|
||||||
|
|
||||||
const handleRemoveHeader = (index: number, fieldId: string) => {
|
|
||||||
setHeaderTypes((prev) => {
|
|
||||||
const newState = { ...prev };
|
|
||||||
delete newState[fieldId];
|
|
||||||
return newState;
|
|
||||||
});
|
|
||||||
remove(index);
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueTypeOptions = [
|
|
||||||
{ label: 'Value', value: 'value' as const },
|
|
||||||
{ label: 'Env Var', value: 'value_from_env' as const },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-4 rounded border-1 p-4">
|
|
||||||
<Box className="flex flex-row items-center justify-between">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text variant="h4" className="font-semibold">
|
|
||||||
Additional headers
|
|
||||||
</Text>
|
|
||||||
<Tooltip title="Custom headers to be sent to the remote GraphQL server">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Button
|
|
||||||
variant="borderless"
|
|
||||||
onClick={() => append({ name: '', value: '', value_from_env: '' })}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="flex flex-col space-y-4">
|
|
||||||
{fields.length > 0 && (
|
|
||||||
<Box className="grid grid-cols-9 gap-4">
|
|
||||||
<Text className="col-span-3">Key</Text>
|
|
||||||
<div className="col-span-1" />
|
|
||||||
<Text className="col-span-4">Value</Text>
|
|
||||||
<div className="col-span-1" />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fields.map((field, index) => {
|
|
||||||
const currentValueType = getHeaderValueType(field.id);
|
|
||||||
const headerErrors = errors?.definition?.headers?.at?.(index);
|
|
||||||
const nameMessage = headerErrors?.name?.message;
|
|
||||||
const objectLevelMessage =
|
|
||||||
headerErrors?.message ?? headerErrors?.root?.message;
|
|
||||||
const combinedMessage = nameMessage ?? objectLevelMessage;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={field.id} className="grid grid-cols-9 items-center gap-4">
|
|
||||||
{combinedMessage && (
|
|
||||||
<HelperText className="col-span-9" error>
|
|
||||||
{combinedMessage}
|
|
||||||
</HelperText>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
{...register(`definition.headers.${index}.name`)}
|
|
||||||
id={`${field.id}-name`}
|
|
||||||
placeholder="Header name"
|
|
||||||
className="col-span-3"
|
|
||||||
hideEmptyHelperText
|
|
||||||
error={Boolean(combinedMessage)}
|
|
||||||
fullWidth
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text className="col-span-1 text-center">:</Text>
|
|
||||||
|
|
||||||
<Box className="col-span-4 flex flex-col gap-1 md:flex-row md:gap-0">
|
|
||||||
<Select
|
|
||||||
className="md:w-40"
|
|
||||||
value={currentValueType}
|
|
||||||
onChange={(_event, inputValue) =>
|
|
||||||
onChangeHeaderValueType(
|
|
||||||
inputValue as 'value' | 'value_from_env',
|
|
||||||
index,
|
|
||||||
field.id,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
placeholder="Select value type"
|
|
||||||
slotProps={{
|
|
||||||
listbox: { className: 'min-w-0 w-full ' },
|
|
||||||
root: { className: 'rounded-r-none' },
|
|
||||||
popper: {
|
|
||||||
disablePortal: false,
|
|
||||||
className: 'z-[10000] w-[240px]',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{valueTypeOptions.map((valueType) => (
|
|
||||||
<Option key={valueType.value} value={valueType.value}>
|
|
||||||
{valueType.label}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
{...register(
|
|
||||||
`definition.headers.${index}.${currentValueType}`,
|
|
||||||
)}
|
|
||||||
id={`${field.id}-${currentValueType}`}
|
|
||||||
className="pl-0"
|
|
||||||
sx={{
|
|
||||||
[`& .${inputBaseClasses.input}`]: {
|
|
||||||
paddingLeft: '8px',
|
|
||||||
},
|
|
||||||
borderLeft: 'none',
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
}}
|
|
||||||
placeholder={
|
|
||||||
currentValueType === 'value'
|
|
||||||
? 'Header value'
|
|
||||||
: 'Env var name'
|
|
||||||
}
|
|
||||||
hideEmptyHelperText
|
|
||||||
error={Boolean(combinedMessage)}
|
|
||||||
helperText=""
|
|
||||||
fullWidth
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="borderless"
|
|
||||||
className="col-span-1"
|
|
||||||
color="error"
|
|
||||||
onClick={() => handleRemoveHeader(index, field.id)}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
|
||||||
import { Form } from '@/components/form/Form';
|
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import type { DialogFormProps } from '@/types/common';
|
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { useFormState } from 'react-hook-form';
|
|
||||||
import * as Yup from 'yup';
|
|
||||||
import AdditionalHeadersEditor from './AdditionalHeadersEditor';
|
|
||||||
import ForwardClientHeadersToggle from './ForwardClientHeadersToggle';
|
|
||||||
import GraphQLCustomizations from './GraphQLCustomizations';
|
|
||||||
import GraphQLServerTimeoutInput from './GraphQLServerTimeoutInput';
|
|
||||||
import GraphQLServiceURLInput from './GraphQLServiceURLInput';
|
|
||||||
import RemoteSchemaCommentInput from './RemoteSchemaCommentInput';
|
|
||||||
import RemoteSchemaNameInput from './RemoteSchemaNameInput';
|
|
||||||
|
|
||||||
type RequireFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
|
||||||
|
|
||||||
type YupInferred = Yup.InferType<typeof baseRemoteSchemaValidationSchema>;
|
|
||||||
|
|
||||||
export type BaseRemoteSchemaFormValues = YupInferred & {
|
|
||||||
definition: RequireFields<
|
|
||||||
YupInferred['definition'],
|
|
||||||
'url' | 'forward_client_headers' | 'timeout_seconds'
|
|
||||||
>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface BaseRemoteSchemaFormProps extends DialogFormProps {
|
|
||||||
/**
|
|
||||||
* Function to be called when the form is submitted.
|
|
||||||
*/
|
|
||||||
onSubmit: (values: BaseRemoteSchemaFormValues) => Promise<void>;
|
|
||||||
/**
|
|
||||||
* Function to be called when the operation is cancelled.
|
|
||||||
*/
|
|
||||||
onCancel?: VoidFunction;
|
|
||||||
/**
|
|
||||||
* Submit button text.
|
|
||||||
*
|
|
||||||
* @default 'Save'
|
|
||||||
*/
|
|
||||||
submitButtonText?: string;
|
|
||||||
/**
|
|
||||||
* Whether the name input should be disabled.
|
|
||||||
*/
|
|
||||||
nameInputDisabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Optional slot to override the default GraphQL customizations section.
|
|
||||||
*/
|
|
||||||
graphQLCustomizationsSlot?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const baseRemoteSchemaValidationSchema = Yup.object({
|
|
||||||
name: Yup.string().required('Name is required.'),
|
|
||||||
comment: Yup.string(),
|
|
||||||
definition: Yup.object({
|
|
||||||
url: Yup.string()
|
|
||||||
.url('Invalid service URL.')
|
|
||||||
.required('Service URL is required.'),
|
|
||||||
forward_client_headers: Yup.boolean().required(
|
|
||||||
'Forward client headers is required.',
|
|
||||||
),
|
|
||||||
headers: Yup.array().of(
|
|
||||||
Yup.object({
|
|
||||||
name: Yup.string().required('Header name is required.'),
|
|
||||||
value: Yup.string(),
|
|
||||||
value_from_env: Yup.string(),
|
|
||||||
}).test(
|
|
||||||
'has-value-or-env',
|
|
||||||
'Either value or value from environment variable must be provided.',
|
|
||||||
(obj) => {
|
|
||||||
const { value, value_from_env } = obj || {};
|
|
||||||
const hasValue = (value ?? '').trim() !== '';
|
|
||||||
const hasEnvValue = (value_from_env ?? '').trim() !== '';
|
|
||||||
return hasValue || hasEnvValue;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeout_seconds: Yup.number()
|
|
||||||
.required('Timeout is required.')
|
|
||||||
.positive('Timeout must be a positive number.')
|
|
||||||
.typeError('Timeout must be a number.'),
|
|
||||||
customization: Yup.object({
|
|
||||||
root_fields_namespace: Yup.string(),
|
|
||||||
type_prefix: Yup.string(),
|
|
||||||
type_suffix: Yup.string(),
|
|
||||||
query_root: Yup.object({
|
|
||||||
parent_type: Yup.string(),
|
|
||||||
prefix: Yup.string(),
|
|
||||||
suffix: Yup.string(),
|
|
||||||
}),
|
|
||||||
mutation_root: Yup.object({
|
|
||||||
parent_type: Yup.string(),
|
|
||||||
prefix: Yup.string(),
|
|
||||||
suffix: Yup.string(),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}).required('Definition is required.'),
|
|
||||||
});
|
|
||||||
|
|
||||||
function FormFooter({
|
|
||||||
onCancel,
|
|
||||||
submitButtonText,
|
|
||||||
location,
|
|
||||||
}: Pick<BaseRemoteSchemaFormProps, 'onCancel' | 'submitButtonText'> &
|
|
||||||
Pick<DialogFormProps, 'location'>) {
|
|
||||||
const { onDirtyStateChange } = useDialog();
|
|
||||||
const { isSubmitting, dirtyFields } = useFormState();
|
|
||||||
|
|
||||||
// react-hook-form's isDirty gets true even if an input field is focused, then
|
|
||||||
// immediately unfocused - we can't rely on that information
|
|
||||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onDirtyStateChange(isDirty, location);
|
|
||||||
}, [isDirty, location, onDirtyStateChange]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 p-2">
|
|
||||||
<Button
|
|
||||||
variant="borderless"
|
|
||||||
color="secondary"
|
|
||||||
onClick={onCancel}
|
|
||||||
tabIndex={isDirty ? -1 : 0}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
loading={isSubmitting}
|
|
||||||
disabled={isSubmitting || !isDirty}
|
|
||||||
type="submit"
|
|
||||||
className="justify-self-end"
|
|
||||||
>
|
|
||||||
{submitButtonText}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BaseRemoteSchemaForm({
|
|
||||||
location,
|
|
||||||
onSubmit: handleExternalSubmit,
|
|
||||||
onCancel,
|
|
||||||
submitButtonText = 'Save',
|
|
||||||
nameInputDisabled = false,
|
|
||||||
graphQLCustomizationsSlot,
|
|
||||||
}: BaseRemoteSchemaFormProps) {
|
|
||||||
return (
|
|
||||||
<Form
|
|
||||||
onSubmit={handleExternalSubmit}
|
|
||||||
className="flex flex-auto flex-col content-between overflow-hidden border-t-1"
|
|
||||||
>
|
|
||||||
<div className="flex-auto overflow-y-auto pb-4">
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
className="flex flex-col gap-3 px-6 py-6 md:grid md:grid-cols-8"
|
|
||||||
>
|
|
||||||
<div className="col-span-6">
|
|
||||||
<RemoteSchemaNameInput disabled={nameInputDisabled} />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-6">
|
|
||||||
<RemoteSchemaCommentInput />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
className="flex flex-col gap-3 border-t-1 px-6 py-6 md:grid md:grid-cols-8"
|
|
||||||
>
|
|
||||||
<div className="col-span-6">
|
|
||||||
<GraphQLServiceURLInput />
|
|
||||||
</div>
|
|
||||||
<div className="col-span-6">
|
|
||||||
<GraphQLServerTimeoutInput />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box
|
|
||||||
component="section"
|
|
||||||
className="flex flex-col gap-3 border-t-1 px-6 py-6"
|
|
||||||
>
|
|
||||||
<Text variant="h4" className="text-lg font-semibold">
|
|
||||||
Headers for remote GraphQL server
|
|
||||||
</Text>
|
|
||||||
<ForwardClientHeadersToggle />
|
|
||||||
<AdditionalHeadersEditor />
|
|
||||||
{graphQLCustomizationsSlot ?? <GraphQLCustomizations />}
|
|
||||||
</Box>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormFooter
|
|
||||||
onCancel={onCancel}
|
|
||||||
submitButtonText={submitButtonText}
|
|
||||||
location={location}
|
|
||||||
/>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { ControlledSwitch } from '@/components/form/ControlledSwitch';
|
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export default function ForwardClientHeadersToggle() {
|
|
||||||
const { register } = useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="flex flex-row justify-between gap-2">
|
|
||||||
<Box className="flex flex-row items-center gap-2">
|
|
||||||
<Text>Forward all headers from client</Text>
|
|
||||||
<Tooltip title="Toggle forwarding headers sent by the client app in the request to your remote GraphQL server">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<ControlledSwitch
|
|
||||||
{...register('definition.forward_client_headers')}
|
|
||||||
className="self-center"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export default function GraphQLCustomizations() {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const { register, getFieldState, formState } =
|
|
||||||
useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
|
|
||||||
const queryRootError = getFieldState(
|
|
||||||
'definition.customization.query_root',
|
|
||||||
formState,
|
|
||||||
).error;
|
|
||||||
const mutationRootError = getFieldState(
|
|
||||||
'definition.customization.mutation_root',
|
|
||||||
formState,
|
|
||||||
).error;
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return (
|
|
||||||
<Box className="space-y-4">
|
|
||||||
<Box className="flex h-8 flex-row items-center justify-between">
|
|
||||||
<Text variant="h4" className="text-lg font-semibold">
|
|
||||||
GraphQL Customizations
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Text variant="body2" color="secondary" className="text-sm">
|
|
||||||
Individual Types and Fields will be editable after saving.{' '}
|
|
||||||
<a
|
|
||||||
href="https://spec.graphql.org/June2018/#example-e2969"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 underline hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Read more
|
|
||||||
</a>{' '}
|
|
||||||
in the official GraphQL spec.
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
size="small"
|
|
||||||
startIcon={<PlusIcon />}
|
|
||||||
onClick={() => setIsOpen(true)}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
|
||||||
Add GQL Customization
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-4">
|
|
||||||
<Box className="flex flex-row items-center justify-between">
|
|
||||||
<Text variant="h4" className="text-lg font-semibold">
|
|
||||||
GraphQL Customizations
|
|
||||||
</Text>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="secondary"
|
|
||||||
size="small"
|
|
||||||
onClick={() => setIsOpen(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Text variant="body2" color="secondary" className="text-sm">
|
|
||||||
Individual Types and Fields will be editable after saving.{' '}
|
|
||||||
<a
|
|
||||||
href="https://spec.graphql.org/June2018/#example-e2969"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-blue-600 underline hover:text-blue-800"
|
|
||||||
>
|
|
||||||
Read more
|
|
||||||
</a>{' '}
|
|
||||||
in the official GraphQL spec.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Box className="space-y-4 rounded border p-4">
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text className="font-medium">Root Field Namespace</Text>
|
|
||||||
<Tooltip title="Root field type names will be prefixed by this name.">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.root_fields_namespace')}
|
|
||||||
id="definition.customization.root_fields_namespace"
|
|
||||||
name="definition.customization.root_fields_namespace"
|
|
||||||
placeholder="namespace_"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-3">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text variant="h4" className="text-lg font-semibold">
|
|
||||||
Types
|
|
||||||
</Text>
|
|
||||||
<Tooltip title="Add a prefix / suffix to all types of the remote schema">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Prefix</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.type_prefix')}
|
|
||||||
id="definition.customization.type_prefix"
|
|
||||||
name="definition.customization.type_prefix"
|
|
||||||
placeholder="prefix_"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Suffix</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.type_suffix')}
|
|
||||||
id="definition.customization.type_suffix"
|
|
||||||
name="definition.customization.type_suffix"
|
|
||||||
placeholder="_suffix"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-3">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text variant="h4" className="text-lg font-semibold">
|
|
||||||
Fields
|
|
||||||
</Text>
|
|
||||||
<Tooltip title="Add a prefix / suffix to the fields of the query / mutation root fields">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-3">
|
|
||||||
<Text variant="h4" className="font-semibold">
|
|
||||||
Query root
|
|
||||||
</Text>
|
|
||||||
{queryRootError?.message && (
|
|
||||||
<Text color="error" className="text-sm">
|
|
||||||
{queryRootError.message}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Type Name</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.query_root.parent_type')}
|
|
||||||
id="definition.customization.query_root.parent_type"
|
|
||||||
name="definition.customization.query_root.parent_type"
|
|
||||||
placeholder="Query/query_root"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Prefix</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.query_root.prefix')}
|
|
||||||
id="definition.customization.query_root.prefix"
|
|
||||||
name="definition.customization.query_root.prefix"
|
|
||||||
placeholder="prefix_"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Suffix</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.query_root.suffix')}
|
|
||||||
id="definition.customization.query_root.suffix"
|
|
||||||
name="definition.customization.query_root.suffix"
|
|
||||||
placeholder="_suffix"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-3">
|
|
||||||
<Text variant="h4" className="font-semibold">
|
|
||||||
Mutation root
|
|
||||||
</Text>
|
|
||||||
{mutationRootError?.message && (
|
|
||||||
<Text color="error" className="text-sm">
|
|
||||||
{mutationRootError.message}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Type Name</Text>
|
|
||||||
<Input
|
|
||||||
{...register(
|
|
||||||
'definition.customization.mutation_root.parent_type',
|
|
||||||
)}
|
|
||||||
id="definition.customization.mutation_root.parent_type"
|
|
||||||
name="definition.customization.mutation_root.parent_type"
|
|
||||||
placeholder="Mutation/mutation_root"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Prefix</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.mutation_root.prefix')}
|
|
||||||
id="definition.customization.mutation_root.prefix"
|
|
||||||
name="definition.customization.mutation_root.prefix"
|
|
||||||
placeholder="prefix_"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Text className="font-medium">Suffix</Text>
|
|
||||||
<Input
|
|
||||||
{...register('definition.customization.mutation_root.suffix')}
|
|
||||||
id="definition.customization.mutation_root.suffix"
|
|
||||||
name="definition.customization.mutation_root.suffix"
|
|
||||||
placeholder="_suffix"
|
|
||||||
hideEmptyHelperText
|
|
||||||
autoComplete="off"
|
|
||||||
variant="inline"
|
|
||||||
fullWidth
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { DEFAULT_REMOTE_SCHEMA_TIMEOUT_SECONDS } from '@/features/orgs/projects/remote-schemas/utils/constants';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export default function GraphQLServerTimeoutInput() {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text>GraphQL Server Timeout</Text>
|
|
||||||
<Tooltip title="Configure timeout for your remote GraphQL server.">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Input
|
|
||||||
{...register('definition.timeout_seconds')}
|
|
||||||
id="definition.timeout_seconds"
|
|
||||||
name="definition.timeout_seconds"
|
|
||||||
placeholder={DEFAULT_REMOTE_SCHEMA_TIMEOUT_SECONDS.toString()}
|
|
||||||
className=""
|
|
||||||
hideEmptyHelperText
|
|
||||||
error={Boolean(errors?.definition?.timeout_seconds)}
|
|
||||||
autoComplete="off"
|
|
||||||
fullWidth
|
|
||||||
helperText={errors?.definition?.timeout_seconds?.message}
|
|
||||||
endAdornment={
|
|
||||||
<Text sx={{ color: 'grey.500' }} className="pr-2">
|
|
||||||
seconds
|
|
||||||
</Text>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export default function GraphQLServiceURLInput() {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text>GraphQL Service URL</Text>
|
|
||||||
<Tooltip title="The URL of the GraphQL service to be used as a remote schema. Environment variables and secrets are available using the {{VARIABLE}} tag. Example: https://{{ENV_VAR}}/endpoint_url.">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Input
|
|
||||||
{...register('definition.url')}
|
|
||||||
id="definition.url"
|
|
||||||
name="definition.url"
|
|
||||||
placeholder="https://graphql-service.example.com or {{ENV_VAR}}/endpoint_url"
|
|
||||||
className=""
|
|
||||||
hideEmptyHelperText
|
|
||||||
error={Boolean(errors?.definition?.url)}
|
|
||||||
autoComplete="off"
|
|
||||||
fullWidth
|
|
||||||
helperText={errors?.definition?.url?.message}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { useFormContext, useFormState } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export default function RemoteSchemaCommentInput() {
|
|
||||||
const { register } = useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
const { errors } = useFormState({ name: 'comment' });
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text>Comment</Text>
|
|
||||||
<Tooltip title="A statement to help describe the remote schema in brief.">
|
|
||||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
|
||||||
<Input
|
|
||||||
{...register('comment')}
|
|
||||||
id="comment"
|
|
||||||
name="comment"
|
|
||||||
placeholder="Comment, e.g. 'Remote schema for the Graphite API'"
|
|
||||||
className=""
|
|
||||||
hideEmptyHelperText
|
|
||||||
error={Boolean(errors.comment)}
|
|
||||||
autoComplete="off"
|
|
||||||
fullWidth
|
|
||||||
helperText={
|
|
||||||
typeof errors.comment?.message === 'string'
|
|
||||||
? errors.comment?.message
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
|
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import type { BaseRemoteSchemaFormValues } from './BaseRemoteSchemaForm';
|
|
||||||
|
|
||||||
export interface RemoteSchemaNameInputProps {
|
|
||||||
/**
|
|
||||||
* Whether the input should be disabled.
|
|
||||||
*/
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RemoteSchemaNameInput({
|
|
||||||
disabled,
|
|
||||||
}: RemoteSchemaNameInputProps) {
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
formState: { errors },
|
|
||||||
} = useFormContext<BaseRemoteSchemaFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-2">
|
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
|
||||||
<Text>Name</Text>
|
|
||||||
</Box>
|
|
||||||
<Input
|
|
||||||
{...register('name')}
|
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
placeholder="Remote Schema Name"
|
|
||||||
disabled={disabled}
|
|
||||||
className=""
|
|
||||||
hideEmptyHelperText
|
|
||||||
error={Boolean(errors?.name)}
|
|
||||||
autoComplete="off"
|
|
||||||
fullWidth
|
|
||||||
helperText={errors?.name?.message}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './BaseRemoteSchemaForm';
|
|
||||||
export { default as BaseTableForm } from './BaseRemoteSchemaForm';
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import type { RemoteSchemaRelationshipType } from '@/features/orgs/projects/remote-schemas/types/remoteSchemas';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import type { DatabaseRelationshipFormValues } from './sections/DatabaseRelationshipForm';
|
|
||||||
import DatabaseRelationshipForm from './sections/DatabaseRelationshipForm';
|
|
||||||
import RelationshipTypeSection from './sections/RelationshipTypeSection';
|
|
||||||
import type { RemoteSchemaRelationshipFormValues } from './sections/RemoteSchemaRelationshipForm';
|
|
||||||
import RemoteSchemaRelationshipForm from './sections/RemoteSchemaRelationshipForm';
|
|
||||||
|
|
||||||
export interface BaseRemoteSchemaRelationshipFormProps {
|
|
||||||
/**
|
|
||||||
* The schema name of the remote schema that is being edited.
|
|
||||||
*/
|
|
||||||
schema: string;
|
|
||||||
/**
|
|
||||||
* The text to display on the submit button.
|
|
||||||
*/
|
|
||||||
submitButtonText?: string;
|
|
||||||
/**
|
|
||||||
* If provided, the form will be pre-filled with the default type, and the type section will be disabled. Used for editing existing relationships.
|
|
||||||
*/
|
|
||||||
defaultType?: RemoteSchemaRelationshipType;
|
|
||||||
defaultValues?:
|
|
||||||
| DatabaseRelationshipFormValues
|
|
||||||
| RemoteSchemaRelationshipFormValues;
|
|
||||||
onSubmit?: (
|
|
||||||
values: DatabaseRelationshipFormValues | RemoteSchemaRelationshipFormValues,
|
|
||||||
) => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the name input is disabled.
|
|
||||||
*/
|
|
||||||
nameInputDisabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BaseRemoteSchemaRelationshipForm({
|
|
||||||
schema,
|
|
||||||
submitButtonText = 'Submit',
|
|
||||||
onSubmit,
|
|
||||||
onCancel,
|
|
||||||
defaultType,
|
|
||||||
defaultValues,
|
|
||||||
disabled,
|
|
||||||
nameInputDisabled,
|
|
||||||
}: BaseRemoteSchemaRelationshipFormProps) {
|
|
||||||
const [type, setType] = useState<RemoteSchemaRelationshipType>(
|
|
||||||
defaultType ?? 'remote-schema',
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-1 flex-col space-y-6 pb-4">
|
|
||||||
<RelationshipTypeSection
|
|
||||||
disabled={!!defaultType || disabled}
|
|
||||||
onChange={setType}
|
|
||||||
value={type}
|
|
||||||
/>
|
|
||||||
{type === 'remote-schema' && (
|
|
||||||
<RemoteSchemaRelationshipForm
|
|
||||||
sourceSchema={schema}
|
|
||||||
defaultValues={
|
|
||||||
defaultType === 'remote-schema'
|
|
||||||
? (defaultValues as
|
|
||||||
| RemoteSchemaRelationshipFormValues
|
|
||||||
| undefined)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={(values) => onSubmit?.(values)}
|
|
||||||
submitButtonText={submitButtonText}
|
|
||||||
disabled={disabled}
|
|
||||||
nameInputDisabled={nameInputDisabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{type === 'database' && (
|
|
||||||
<DatabaseRelationshipForm
|
|
||||||
sourceSchema={schema}
|
|
||||||
defaultValues={
|
|
||||||
defaultType === 'database'
|
|
||||||
? (defaultValues as DatabaseRelationshipFormValues | undefined)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onCancel={onCancel}
|
|
||||||
onSubmit={(values) => onSubmit?.(values)}
|
|
||||||
submitButtonText={submitButtonText}
|
|
||||||
disabled={disabled}
|
|
||||||
nameInputDisabled={nameInputDisabled}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as BaseRemoteSchemaRelationshipForm } from './BaseRemoteSchemaRelationshipForm';
|
|
||||||
export type { DatabaseRelationshipFormValues } from './sections/DatabaseRelationshipForm';
|
|
||||||
export type { RemoteSchemaRelationshipFormValues } from './sections/RemoteSchemaRelationshipForm';
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import { Input } from '@/components/ui/v3/input';
|
|
||||||
import { Popover, PopoverTrigger } from '@/components/ui/v3/popover';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/v3/select';
|
|
||||||
import { Spinner } from '@/components/ui/v3/spinner';
|
|
||||||
import { useIntrospectRemoteSchemaQuery } from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery';
|
|
||||||
import getSourceTypes from '@/features/orgs/projects/remote-schemas/utils/getSourceTypes';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Anchor, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import FieldToColumnMapSelector from './FieldToColumnMapSelector';
|
|
||||||
import SourceTypeCombobox from './SourceTypeCombobox';
|
|
||||||
import TargetTableCombobox from './TargetTableCombobox';
|
|
||||||
|
|
||||||
export interface DatabaseRelationshipFormProps {
|
|
||||||
sourceSchema: string;
|
|
||||||
onSubmit: (values: DatabaseRelationshipFormValues) => void;
|
|
||||||
submitButtonText?: string;
|
|
||||||
onCancel?: () => void;
|
|
||||||
defaultValues?: DatabaseRelationshipFormValues;
|
|
||||||
disabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the name input should be disabled.
|
|
||||||
*/
|
|
||||||
nameInputDisabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DatabaseRelationshipFormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, { message: 'Name is required' }),
|
|
||||||
sourceRemoteSchema: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: 'Source remote schema is required' }),
|
|
||||||
sourceType: z.string().min(1, { message: 'Source type is required' }),
|
|
||||||
table: z
|
|
||||||
.object({
|
|
||||||
name: z.string().min(1, { message: 'Table name is required' }),
|
|
||||||
schema: z.string().min(1, { message: 'Table schema is required' }),
|
|
||||||
})
|
|
||||||
.refine((value) => Boolean(value?.name) && Boolean(value?.schema), {
|
|
||||||
message: 'Target table is required',
|
|
||||||
}),
|
|
||||||
relationshipType: z.enum(['array', 'object']),
|
|
||||||
fieldMapping: z.array(
|
|
||||||
z.object({
|
|
||||||
sourceField: z.string().min(1, { message: 'Source field is required' }),
|
|
||||||
referenceColumn: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: 'Reference column is required' }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function DatabaseRelationshipForm({
|
|
||||||
sourceSchema,
|
|
||||||
onSubmit,
|
|
||||||
submitButtonText,
|
|
||||||
onCancel,
|
|
||||||
defaultValues,
|
|
||||||
disabled,
|
|
||||||
nameInputDisabled,
|
|
||||||
}: DatabaseRelationshipFormProps) {
|
|
||||||
const form = useForm<DatabaseRelationshipFormValues>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultValues?.name || '',
|
|
||||||
sourceRemoteSchema: defaultValues?.sourceRemoteSchema || sourceSchema,
|
|
||||||
sourceType: defaultValues?.sourceType || '',
|
|
||||||
relationshipType: defaultValues?.relationshipType || 'array',
|
|
||||||
table: {
|
|
||||||
name: defaultValues?.table?.name || '',
|
|
||||||
schema: defaultValues?.table?.schema || '',
|
|
||||||
},
|
|
||||||
fieldMapping: defaultValues?.fieldMapping || [
|
|
||||||
{ sourceField: '', referenceColumn: '' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSubmitting } = form.formState;
|
|
||||||
|
|
||||||
const { data: introspectionData } = useIntrospectRemoteSchemaQuery(
|
|
||||||
sourceSchema,
|
|
||||||
{
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!sourceSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceTypes = getSourceTypes(introspectionData);
|
|
||||||
|
|
||||||
function handleSubmit(values: z.infer<typeof formSchema>) {
|
|
||||||
return onSubmit(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
|
||||||
className="flex flex-1 flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 px-6">
|
|
||||||
<h4 className="text-xl font-medium tracking-tight">Source</h4>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center gap-2">
|
|
||||||
Relationship name
|
|
||||||
<Tooltip title="This will be used as the field name in the source type.">
|
|
||||||
<InfoIcon
|
|
||||||
aria-label="Info"
|
|
||||||
className="h-4 w-4"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Relationship name"
|
|
||||||
className={cn({
|
|
||||||
'border-destructive': form.formState.errors.name,
|
|
||||||
})}
|
|
||||||
{...field}
|
|
||||||
disabled={disabled || nameInputDisabled}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sourceRemoteSchema"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-1 flex-col">
|
|
||||||
<FormLabel>Source Remote Schema</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
|
||||||
!field.value && 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{sourceSchema}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SourceTypeCombobox sourceTypes={sourceTypes} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center justify-center gap-2 border-b-1 border-t-1 border-muted-foreground/20 py-4">
|
|
||||||
<Anchor className="h-4 w-4" />
|
|
||||||
<h4 className="text-xl font-medium tracking-tight">Type Mapped To</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 px-6">
|
|
||||||
<TargetTableCombobox disabled={disabled} />
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="relationshipType"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Type</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
<SelectValue placeholder="Select a type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="array">Array Relationship</SelectItem>
|
|
||||||
<SelectItem value="object">Object Relationship</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<FieldToColumnMapSelector
|
|
||||||
sourceSchema={sourceSchema}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="mt-auto flex justify-between gap-2 border-t-1 border-foreground/20 px-6 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting || disabled}>
|
|
||||||
{isSubmitting ? <Spinner /> : submitButtonText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
|
||||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
|
||||||
import { useIntrospectRemoteSchemaQuery } from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery';
|
|
||||||
import convertIntrospectionToSchema from '@/features/orgs/projects/remote-schemas/utils/convertIntrospectionToSchema';
|
|
||||||
import { isObjectType } from 'graphql';
|
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
|
||||||
import type { DatabaseRelationshipFormValues } from './DatabaseRelationshipForm';
|
|
||||||
import FieldToColumnMapSelectorItem from './FieldToColumnMapSelectorItem';
|
|
||||||
|
|
||||||
export interface FieldToColumnMapSelectorProps {
|
|
||||||
sourceSchema: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FieldToColumnMapSelector({
|
|
||||||
sourceSchema,
|
|
||||||
disabled,
|
|
||||||
}: FieldToColumnMapSelectorProps) {
|
|
||||||
const form = useFormContext<DatabaseRelationshipFormValues>();
|
|
||||||
|
|
||||||
const tableInfo = form.watch('table');
|
|
||||||
const { schema, name: table } = tableInfo;
|
|
||||||
|
|
||||||
const selectedSourceType = form.watch('sourceType');
|
|
||||||
|
|
||||||
const { data: introspectionData } = useIntrospectRemoteSchemaQuery(
|
|
||||||
sourceSchema,
|
|
||||||
{
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!sourceSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceFields =
|
|
||||||
introspectionData && selectedSourceType
|
|
||||||
? (() => {
|
|
||||||
const graphqlSchema = convertIntrospectionToSchema(introspectionData);
|
|
||||||
|
|
||||||
if (!graphqlSchema) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = graphqlSchema.getType(selectedSourceType);
|
|
||||||
|
|
||||||
if (isObjectType(type)) {
|
|
||||||
const fields = type.getFields();
|
|
||||||
return Object.keys(fields).map((fieldName) => ({
|
|
||||||
label: fieldName,
|
|
||||||
value: fieldName,
|
|
||||||
type: fields[fieldName].type.toString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
})()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const { data } = useTableQuery([`default.${schema}.${table}`], {
|
|
||||||
schema,
|
|
||||||
table,
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!schema && !!table,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const columns =
|
|
||||||
data?.columns
|
|
||||||
?.map((column) => (column.column_name as string) ?? null)
|
|
||||||
.filter(Boolean) ?? [];
|
|
||||||
|
|
||||||
const { fields, append, remove } =
|
|
||||||
useFieldArray<DatabaseRelationshipFormValues>({
|
|
||||||
name: 'fieldMapping',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="mx-2 space-y-4 rounded border-1 p-4">
|
|
||||||
<Box className="flex flex-col space-y-4">
|
|
||||||
<Box className="grid grid-cols-8 items-center gap-4">
|
|
||||||
<Text className="col-span-3">Source Field</Text>
|
|
||||||
<div className="col-span-1" />
|
|
||||||
<Text className="col-span-3">Reference Column</Text>
|
|
||||||
<Button
|
|
||||||
variant="borderless"
|
|
||||||
className="col-span-1"
|
|
||||||
onClick={() => append({ sourceField: '', referenceColumn: '' })}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<PlusIcon className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{fields.map((field, index) => (
|
|
||||||
<Box key={field.id} className="grid grid-cols-8 items-center gap-4">
|
|
||||||
<FieldToColumnMapSelectorItem
|
|
||||||
columns={columns}
|
|
||||||
sourceFields={sourceFields}
|
|
||||||
itemIndex={index}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="borderless"
|
|
||||||
className="col-span-1"
|
|
||||||
color="error"
|
|
||||||
onClick={() => remove(index)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<TrashIcon className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,206 +0,0 @@
|
|||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/v3/command';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/v3/popover';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { DatabaseRelationshipFormValues } from './DatabaseRelationshipForm';
|
|
||||||
|
|
||||||
export interface FieldToColumnMapSelectorItemProps {
|
|
||||||
itemIndex: number;
|
|
||||||
sourceFields: { label: string; value: string; type: string }[];
|
|
||||||
columns: string[];
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function FieldToColumnMapSelectorItem({
|
|
||||||
itemIndex,
|
|
||||||
sourceFields,
|
|
||||||
columns,
|
|
||||||
disabled,
|
|
||||||
}: FieldToColumnMapSelectorItemProps) {
|
|
||||||
const form = useFormContext<DatabaseRelationshipFormValues>();
|
|
||||||
|
|
||||||
const [sourceFieldOpen, setSourceFieldOpen] = useState(false);
|
|
||||||
const [referenceColumnOpen, setReferenceColumnOpen] = useState(false);
|
|
||||||
|
|
||||||
const fieldMappings = form.watch('fieldMapping');
|
|
||||||
|
|
||||||
const getAvailableSourceFields = (currentIndex: number) => {
|
|
||||||
const selectedFieldsInOtherRows = fieldMappings
|
|
||||||
.map((mapping, index) =>
|
|
||||||
index !== currentIndex ? mapping.sourceField : null,
|
|
||||||
)
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
return sourceFields.filter(
|
|
||||||
(field) => !selectedFieldsInOtherRows.includes(field.value),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`fieldMapping.${itemIndex}.sourceField`}
|
|
||||||
render={({ field: sourceFieldControl }) => (
|
|
||||||
<FormItem className="col-span-3">
|
|
||||||
<Popover open={sourceFieldOpen} onOpenChange={setSourceFieldOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
|
||||||
!sourceFieldControl.value && 'text-muted-foreground',
|
|
||||||
{
|
|
||||||
'border-destructive':
|
|
||||||
form.formState.errors.fieldMapping?.[itemIndex]
|
|
||||||
?.sourceField,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{sourceFieldControl.value
|
|
||||||
? sourceFields.find(
|
|
||||||
(sourceField) =>
|
|
||||||
sourceField.value === sourceFieldControl.value,
|
|
||||||
)?.label
|
|
||||||
: 'Select field'}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search field..." className="h-9" />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>Select a source type first.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getAvailableSourceFields(itemIndex).map(
|
|
||||||
(sourceField) => (
|
|
||||||
<CommandItem
|
|
||||||
value={sourceField.value}
|
|
||||||
key={sourceField.value}
|
|
||||||
onSelect={() => {
|
|
||||||
sourceFieldControl.onChange(sourceField.value);
|
|
||||||
setSourceFieldOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{sourceField.label} ({sourceField.type})
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'ml-auto',
|
|
||||||
sourceField.value === sourceFieldControl.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Text className="col-span-1 text-center">:</Text>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`fieldMapping.${itemIndex}.referenceColumn`}
|
|
||||||
render={({ field: columnField }) => (
|
|
||||||
<FormItem className="col-span-3">
|
|
||||||
<Popover
|
|
||||||
open={referenceColumnOpen}
|
|
||||||
onOpenChange={setReferenceColumnOpen}
|
|
||||||
>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
|
||||||
!columnField.value && 'text-muted-foreground',
|
|
||||||
{
|
|
||||||
'border-destructive':
|
|
||||||
form.formState.errors.fieldMapping?.[itemIndex]
|
|
||||||
?.referenceColumn,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
{columnField.value
|
|
||||||
? columns.find((column) => column === columnField.value)
|
|
||||||
: 'Select column'}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search column..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>Select a target table first.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{columns?.map((column) => (
|
|
||||||
<CommandItem
|
|
||||||
value={column}
|
|
||||||
key={column}
|
|
||||||
onSelect={() => {
|
|
||||||
columnField.onChange(column);
|
|
||||||
setReferenceColumnOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{column}
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'ml-auto',
|
|
||||||
column === columnField.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Label } from '@/components/ui/v3/label';
|
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
|
|
||||||
import type { RemoteSchemaRelationshipType } from '@/features/orgs/projects/remote-schemas/types/remoteSchemas';
|
|
||||||
|
|
||||||
export interface RelationshipTypeSectionProps {
|
|
||||||
onChange: (value: RemoteSchemaRelationshipType) => void;
|
|
||||||
value: RemoteSchemaRelationshipType;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RelationshipTypeSection({
|
|
||||||
onChange,
|
|
||||||
value,
|
|
||||||
disabled,
|
|
||||||
}: RelationshipTypeSectionProps) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 px-6">
|
|
||||||
<div>
|
|
||||||
<h4 className="text-lg font-medium">Relationship Type</h4>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{disabled
|
|
||||||
? 'Type of the relationship you want to edit.'
|
|
||||||
: 'Choose the type of relationship you want to create.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={onChange}
|
|
||||||
value={value}
|
|
||||||
className="flex flex-row gap-8"
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Label
|
|
||||||
htmlFor="remote-schema"
|
|
||||||
className={`flex w-full flex-row items-center justify-between space-y-0 rounded-md border p-3 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center space-x-3">
|
|
||||||
<RadioGroupItem value="remote-schema" id="remote-schema" />
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
<div className="text-md font-semibold">Remote Schema</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Relationship from this remote schema to another remote schema.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex w-full">
|
|
||||||
<Label
|
|
||||||
htmlFor="database"
|
|
||||||
className={`flex w-full flex-row items-center justify-between space-y-0 rounded-md border p-3 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center space-x-3">
|
|
||||||
<RadioGroupItem value="database" id="database" />
|
|
||||||
<div className="flex flex-col space-y-1">
|
|
||||||
<div className="text-md font-semibold">Database</div>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Relationship from this remote schema to a database table.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
|
||||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
|
||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
|
||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import { Input } from '@/components/ui/v3/input';
|
|
||||||
import { Popover, PopoverTrigger } from '@/components/ui/v3/popover';
|
|
||||||
import { Spinner } from '@/components/ui/v3/spinner';
|
|
||||||
import { useGetRemoteSchemas } from '@/features/orgs/projects/remote-schemas/hooks/useGetRemoteSchemas';
|
|
||||||
import { useIntrospectRemoteSchemaQuery } from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery';
|
|
||||||
import getQueryTypeFields from '@/features/orgs/projects/remote-schemas/utils/getQueryTypeFields';
|
|
||||||
import getSourceTypes from '@/features/orgs/projects/remote-schemas/utils/getSourceTypes';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
|
||||||
import { Anchor, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useForm } from 'react-hook-form';
|
|
||||||
import { z } from 'zod';
|
|
||||||
import SchemaToArgumentMapSelector from './SchemaToArgumentMapSelector';
|
|
||||||
import SourceTypeCombobox from './SourceTypeCombobox';
|
|
||||||
import TargetRemoteSchemaCombobox from './TargetRemoteSchemaCombobox';
|
|
||||||
import TargetRemoteSchemaFieldCombobox from './TargetRemoteSchemaFieldCombobox';
|
|
||||||
|
|
||||||
export interface RemoteSchemaRelationshipFormProps {
|
|
||||||
sourceSchema: string;
|
|
||||||
onSubmit: (values: RemoteSchemaRelationshipFormValues) => void;
|
|
||||||
onCancel?: () => void;
|
|
||||||
submitButtonText?: string;
|
|
||||||
defaultValues?: RemoteSchemaRelationshipFormValues;
|
|
||||||
disabled?: boolean;
|
|
||||||
/**
|
|
||||||
* Whether the name input should be disabled.
|
|
||||||
*/
|
|
||||||
nameInputDisabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type RemoteSchemaRelationshipFormValues = z.infer<typeof formSchema>;
|
|
||||||
|
|
||||||
const formSchema = z.object({
|
|
||||||
name: z.string().min(1, { message: 'Relationship name is required' }),
|
|
||||||
sourceRemoteSchema: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: 'Source remote schema is required' }),
|
|
||||||
|
|
||||||
targetRemoteSchema: z
|
|
||||||
.string()
|
|
||||||
.min(1, { message: 'Target remote schema is required' }),
|
|
||||||
targetField: z.string().min(1, { message: 'Target field is required' }),
|
|
||||||
sourceType: z.string().min(1, { message: 'Source type is required' }),
|
|
||||||
mappings: z.array(
|
|
||||||
z.object({
|
|
||||||
argument: z.string().min(1, { message: 'Argument is required' }),
|
|
||||||
type: z.enum(['sourceTypeField', 'staticValue']),
|
|
||||||
value: z.string().min(1, { message: 'Value is required' }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
export default function RemoteSchemaRelationshipForm({
|
|
||||||
sourceSchema,
|
|
||||||
onSubmit,
|
|
||||||
submitButtonText,
|
|
||||||
onCancel,
|
|
||||||
defaultValues,
|
|
||||||
disabled,
|
|
||||||
nameInputDisabled,
|
|
||||||
}: RemoteSchemaRelationshipFormProps) {
|
|
||||||
const form = useForm<RemoteSchemaRelationshipFormValues>({
|
|
||||||
resolver: zodResolver(formSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultValues?.name || '',
|
|
||||||
sourceRemoteSchema: defaultValues?.sourceRemoteSchema || sourceSchema,
|
|
||||||
targetRemoteSchema: defaultValues?.targetRemoteSchema || '',
|
|
||||||
sourceType: defaultValues?.sourceType || '',
|
|
||||||
targetField: defaultValues?.targetField || '',
|
|
||||||
mappings: defaultValues?.mappings || [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { isSubmitting } = form.formState;
|
|
||||||
|
|
||||||
const { data: remoteSchemas, status: remoteSchemasQueryStatus } =
|
|
||||||
useGetRemoteSchemas();
|
|
||||||
|
|
||||||
const { data: sourceIntrospectionData } = useIntrospectRemoteSchemaQuery(
|
|
||||||
sourceSchema,
|
|
||||||
{
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!sourceSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const targetRemoteSchemaValue = form.watch('targetRemoteSchema');
|
|
||||||
|
|
||||||
const { data: targetIntrospectionData } = useIntrospectRemoteSchemaQuery(
|
|
||||||
targetRemoteSchemaValue,
|
|
||||||
{
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!targetRemoteSchemaValue,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceTypes = getSourceTypes(sourceIntrospectionData);
|
|
||||||
|
|
||||||
const targetFields = getQueryTypeFields(targetIntrospectionData);
|
|
||||||
|
|
||||||
function handleSubmit(values: z.infer<typeof formSchema>) {
|
|
||||||
return onSubmit(values);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (remoteSchemasQueryStatus === 'loading' || !remoteSchemas) {
|
|
||||||
return (
|
|
||||||
<ActivityIndicator
|
|
||||||
delay={1000}
|
|
||||||
label="Loading remote schemas..."
|
|
||||||
className="justify-center"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
onSubmit={form.handleSubmit(handleSubmit)}
|
|
||||||
className="flex flex-1 flex-col gap-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 px-6">
|
|
||||||
<h4 className="text-xl font-medium tracking-tight">Source</h4>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel className="flex flex-row items-center gap-2">
|
|
||||||
Relationship name
|
|
||||||
<Tooltip title="This will be used as the field name in the source type.">
|
|
||||||
<InfoIcon
|
|
||||||
aria-label="Info"
|
|
||||||
className="h-4 w-4"
|
|
||||||
color="primary"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
placeholder="Relationship name"
|
|
||||||
className={cn({
|
|
||||||
'border-destructive': form.formState.errors.name,
|
|
||||||
})}
|
|
||||||
{...field}
|
|
||||||
disabled={disabled || nameInputDisabled}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sourceRemoteSchema"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-1 flex-col">
|
|
||||||
<FormLabel>Source Remote Schema</FormLabel>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
|
||||||
!field.value && 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{sourceSchema}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<SourceTypeCombobox sourceTypes={sourceTypes} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row items-center justify-center gap-2 border-b-1 border-t-1 border-muted-foreground/20 py-4">
|
|
||||||
<Anchor className="h-4 w-4" />
|
|
||||||
<h4 className="text-xl font-medium tracking-tight">Type Mapped To</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 px-6">
|
|
||||||
<div className="flex flex-row gap-4">
|
|
||||||
<TargetRemoteSchemaCombobox
|
|
||||||
disabled={disabled}
|
|
||||||
remoteSchemas={remoteSchemas}
|
|
||||||
/>
|
|
||||||
<TargetRemoteSchemaFieldCombobox
|
|
||||||
disabled={disabled}
|
|
||||||
targetFields={targetFields}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<SchemaToArgumentMapSelector
|
|
||||||
sourceSchema={sourceSchema}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="mt-auto flex justify-between gap-2 border-t-1 border-foreground/20 px-6 pt-4">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
onClick={onCancel}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isSubmitting || disabled}>
|
|
||||||
{isSubmitting ? <Spinner /> : submitButtonText}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
import { Box } from '@/components/ui/v2/Box';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { Checkbox } from '@/components/ui/v3/checkbox';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/v3/select';
|
|
||||||
import { useIntrospectRemoteSchemaQuery } from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery';
|
|
||||||
import convertIntrospectionToSchema from '@/features/orgs/projects/remote-schemas/utils/convertIntrospectionToSchema';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { isObjectType } from 'graphql';
|
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
|
||||||
import type { RemoteSchemaRelationshipFormValues } from './RemoteSchemaRelationshipForm';
|
|
||||||
import SchemaToArgumentMapSelectorValue from './SchemaToArgumentMapSelectorValue';
|
|
||||||
|
|
||||||
export interface SchemaToArgumentMapSelectorProps {
|
|
||||||
sourceSchema: string;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SchemaToArgumentMapSelector({
|
|
||||||
sourceSchema,
|
|
||||||
disabled,
|
|
||||||
}: SchemaToArgumentMapSelectorProps) {
|
|
||||||
const form = useFormContext<RemoteSchemaRelationshipFormValues>();
|
|
||||||
|
|
||||||
const selectedSourceType = form.watch('sourceType');
|
|
||||||
const selectedTargetRemoteSchema = form.watch('targetRemoteSchema');
|
|
||||||
const selectedTargetField = form.watch('targetField');
|
|
||||||
|
|
||||||
const { data: sourceIntrospectionData } = useIntrospectRemoteSchemaQuery(
|
|
||||||
sourceSchema,
|
|
||||||
{
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!sourceSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: targetIntrospectionData } = useIntrospectRemoteSchemaQuery(
|
|
||||||
selectedTargetRemoteSchema,
|
|
||||||
{
|
|
||||||
queryOptions: {
|
|
||||||
enabled: !!selectedTargetRemoteSchema,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const sourceFields =
|
|
||||||
sourceIntrospectionData && selectedSourceType
|
|
||||||
? (() => {
|
|
||||||
const graphqlSchema = convertIntrospectionToSchema(
|
|
||||||
sourceIntrospectionData,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!graphqlSchema) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = graphqlSchema.getType(selectedSourceType);
|
|
||||||
|
|
||||||
if (isObjectType(type)) {
|
|
||||||
const fields = type.getFields();
|
|
||||||
return Object.keys(fields).map((fieldName) => ({
|
|
||||||
label: fieldName,
|
|
||||||
value: fieldName,
|
|
||||||
type: fields[fieldName].type.toString(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
})()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const targetArguments =
|
|
||||||
targetIntrospectionData && selectedTargetField
|
|
||||||
? (() => {
|
|
||||||
const graphqlSchema = convertIntrospectionToSchema(
|
|
||||||
targetIntrospectionData,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!graphqlSchema) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryType = graphqlSchema.getQueryType();
|
|
||||||
|
|
||||||
if (!queryType) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const fields = queryType.getFields();
|
|
||||||
const targetFieldObject = fields[selectedTargetField];
|
|
||||||
|
|
||||||
if (!targetFieldObject?.args) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return targetFieldObject.args.map((arg) => ({
|
|
||||||
label: arg.name,
|
|
||||||
value: arg.name,
|
|
||||||
type: arg.type.toString(),
|
|
||||||
required: arg.type.toString().includes('!'),
|
|
||||||
}));
|
|
||||||
})()
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const { fields, append, remove } =
|
|
||||||
useFieldArray<RemoteSchemaRelationshipFormValues>({
|
|
||||||
name: 'mappings',
|
|
||||||
});
|
|
||||||
|
|
||||||
const isArgumentSelected = (argumentName: string) =>
|
|
||||||
fields.some((field) => field.argument === argumentName);
|
|
||||||
|
|
||||||
const getArgumentMappingIndex = (argumentName: string) =>
|
|
||||||
fields.findIndex((field) => field.argument === argumentName);
|
|
||||||
|
|
||||||
const handleArgumentToggle = (argumentName: string, checked: boolean) => {
|
|
||||||
if (checked) {
|
|
||||||
append({
|
|
||||||
argument: argumentName,
|
|
||||||
type: 'sourceTypeField',
|
|
||||||
value: '',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const index = getArgumentMappingIndex(argumentName);
|
|
||||||
if (index !== -1) {
|
|
||||||
remove(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!selectedTargetField) {
|
|
||||||
return (
|
|
||||||
<Box className="space-y-4 rounded border-1 p-4">
|
|
||||||
<Text className="text-sm text-muted-foreground">
|
|
||||||
Select a target field to configure argument mappings.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="space-y-4 rounded border-1 p-4">
|
|
||||||
<Box className="flex flex-col space-y-4">
|
|
||||||
<Text className="text-lg font-semibold">
|
|
||||||
Configure arguments for {selectedTargetField}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{targetArguments.length === 0 ? (
|
|
||||||
<Text className="text-sm text-muted-foreground">
|
|
||||||
No selectable items available for this type
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{targetArguments.map((argument) => {
|
|
||||||
const isSelected = isArgumentSelected(argument.value);
|
|
||||||
const mappingIndex = getArgumentMappingIndex(argument.value);
|
|
||||||
|
|
||||||
const currentType =
|
|
||||||
mappingIndex !== -1
|
|
||||||
? form.watch(`mappings.${mappingIndex}.type`)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={argument.value} className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Checkbox
|
|
||||||
id={`arg-${argument.value}`}
|
|
||||||
checked={isSelected}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
handleArgumentToggle(argument.value, checked as boolean)
|
|
||||||
}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor={`arg-${argument.value}`}
|
|
||||||
className="cursor-pointer text-sm font-medium"
|
|
||||||
>
|
|
||||||
{argument.label}
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">
|
|
||||||
({argument.type})
|
|
||||||
</span>
|
|
||||||
{argument.required && (
|
|
||||||
<span className="ml-1 text-xs text-red-500">*</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isSelected && mappingIndex !== -1 && (
|
|
||||||
<div className="ml-6 flex items-center space-x-0">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`mappings.${mappingIndex}.type`}
|
|
||||||
render={({ field: typeField }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Fill From</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={typeField.onChange}
|
|
||||||
defaultValue={typeField.value}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger
|
|
||||||
className={cn(
|
|
||||||
'w-40 rounded-r-none border-r-0',
|
|
||||||
{
|
|
||||||
'border-destructive':
|
|
||||||
form.formState.errors.mappings?.[
|
|
||||||
mappingIndex
|
|
||||||
]?.type,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="Type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="sourceTypeField">
|
|
||||||
Source Field
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="staticValue">
|
|
||||||
Static Value
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SchemaToArgumentMapSelectorValue
|
|
||||||
mappingIndex={mappingIndex}
|
|
||||||
currentType={currentType}
|
|
||||||
sourceFields={sourceFields}
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/v3/command';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import { Input } from '@/components/ui/v3/input';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/v3/popover';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { RemoteSchemaRelationshipFormValues } from './RemoteSchemaRelationshipForm';
|
|
||||||
|
|
||||||
export interface SchemaToArgumentMapSelectorValueProps {
|
|
||||||
mappingIndex: number;
|
|
||||||
currentType: 'sourceTypeField' | 'staticValue' | null;
|
|
||||||
sourceFields: { label: string; value: string; type: string }[];
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SchemaToArgumentMapSelectorValue({
|
|
||||||
mappingIndex,
|
|
||||||
currentType,
|
|
||||||
sourceFields,
|
|
||||||
disabled,
|
|
||||||
}: SchemaToArgumentMapSelectorValueProps) {
|
|
||||||
const form = useFormContext<RemoteSchemaRelationshipFormValues>();
|
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`mappings.${mappingIndex}.value`}
|
|
||||||
render={({ field: valueField }) => (
|
|
||||||
<FormItem className="flex-1">
|
|
||||||
{currentType === 'sourceTypeField' ? (
|
|
||||||
<>
|
|
||||||
<FormLabel>From Source Type Field</FormLabel>
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between rounded-l-none',
|
|
||||||
!valueField.value && 'text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{valueField.value
|
|
||||||
? sourceFields.find(
|
|
||||||
(field) => field.value === valueField.value,
|
|
||||||
)?.label
|
|
||||||
: 'Select source field'}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search field..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No source fields found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{sourceFields.map((field) => (
|
|
||||||
<CommandItem
|
|
||||||
value={field.value}
|
|
||||||
key={field.value}
|
|
||||||
onSelect={() => {
|
|
||||||
valueField.onChange(field.value);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{field.label} ({field.type})
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'ml-auto',
|
|
||||||
field.value === valueField.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FormLabel>Static Value</FormLabel>
|
|
||||||
<Input
|
|
||||||
{...valueField}
|
|
||||||
placeholder="Enter static value"
|
|
||||||
className="rounded-l-none"
|
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/v3/command';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/v3/popover';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { DatabaseRelationshipFormValues } from './DatabaseRelationshipForm';
|
|
||||||
import type { RemoteSchemaRelationshipFormValues } from './RemoteSchemaRelationshipForm';
|
|
||||||
|
|
||||||
export interface SourceTypeComboboxProps {
|
|
||||||
disabled?: boolean;
|
|
||||||
sourceTypes: { label: string; value: string }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SourceTypeCombobox({
|
|
||||||
disabled,
|
|
||||||
sourceTypes,
|
|
||||||
}: SourceTypeComboboxProps) {
|
|
||||||
const form = useFormContext<
|
|
||||||
DatabaseRelationshipFormValues | RemoteSchemaRelationshipFormValues
|
|
||||||
>();
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="sourceType"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-1 flex-col">
|
|
||||||
<FormLabel>Source Type</FormLabel>
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
disabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
|
||||||
!field.value && 'text-muted-foreground',
|
|
||||||
{ 'border-destructive': form.formState.errors.sourceType },
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? sourceTypes.find((type) => type.value === field.value)
|
|
||||||
?.label
|
|
||||||
: 'Select type'}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search source type..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No source type found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{sourceTypes.map((type) => (
|
|
||||||
<CommandItem
|
|
||||||
value={type.label}
|
|
||||||
key={type.value}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue('sourceType', type.value, {
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldTouch: true,
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{type.label}
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'ml-auto',
|
|
||||||
type.value === field.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/v3/command';
|
|
||||||
import {
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage,
|
|
||||||
} from '@/components/ui/v3/form';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/v3/popover';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import type { RemoteSchemaInfo } from '@/utils/hasura-api/generated/schemas';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useFormContext } from 'react-hook-form';
|
|
||||||
import type { RemoteSchemaRelationshipFormValues } from './RemoteSchemaRelationshipForm';
|
|
||||||
|
|
||||||
export interface TargetRemoteSchemaComboboxProps {
|
|
||||||
disabled?: boolean;
|
|
||||||
remoteSchemas: RemoteSchemaInfo[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TargetRemoteSchemaCombobox({
|
|
||||||
disabled,
|
|
||||||
remoteSchemas,
|
|
||||||
}: TargetRemoteSchemaComboboxProps) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const form = useFormContext<RemoteSchemaRelationshipFormValues>();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="targetRemoteSchema"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="flex flex-1 flex-col">
|
|
||||||
<FormLabel>Target Remote Schema</FormLabel>
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<FormControl>
|
|
||||||
<Button
|
|
||||||
disabled={disabled}
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
'w-full justify-between',
|
|
||||||
!field.value && 'text-muted-foreground',
|
|
||||||
{
|
|
||||||
'border-destructive':
|
|
||||||
form.formState.errors.targetRemoteSchema,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{field.value
|
|
||||||
? remoteSchemas.find(
|
|
||||||
(schema) => schema.name === field.value,
|
|
||||||
)?.name
|
|
||||||
: 'Select remote schema'}
|
|
||||||
<ChevronsUpDown className="opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput
|
|
||||||
placeholder="Search remote schema..."
|
|
||||||
className="h-9"
|
|
||||||
/>
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No remote schema found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{remoteSchemas.map((schema) => (
|
|
||||||
<CommandItem
|
|
||||||
value={schema.name}
|
|
||||||
key={schema.name}
|
|
||||||
onSelect={() => {
|
|
||||||
form.setValue('targetRemoteSchema', schema.name, {
|
|
||||||
shouldValidate: true,
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldTouch: true,
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{schema.name}
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'ml-auto',
|
|
||||||
schema.name === field.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user