Compare commits
10 Commits
@nhost/das
...
storage@0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
438355bff0 | ||
|
|
62b97838fe | ||
|
|
d287191f7a | ||
|
|
b8d2127b06 | ||
|
|
28cec232c8 | ||
|
|
fe853da133 | ||
|
|
c4445135bf | ||
|
|
db7366dfc7 | ||
|
|
31c503e458 | ||
|
|
187d35412e |
6
.github/workflows/ci_release.yaml
vendored
6
.github/workflows/ci_release.yaml
vendored
@@ -36,7 +36,7 @@ jobs:
|
||||
cli:
|
||||
needs: extract-project
|
||||
if: needs.extract-project.outputs.project == 'cli'
|
||||
uses: ./.github/workflows/cli_wf_release.yaml
|
||||
uses: ./.github/workflows/cli_release.yaml
|
||||
with:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
dashboard:
|
||||
needs: extract-project
|
||||
if: needs.extract-project.outputs.project == '@nhost/dashboard'
|
||||
uses: ./.github/workflows/dashboard_wf_release.yaml
|
||||
uses: ./.github/workflows/dashboard_release.yaml
|
||||
with:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: ${{ needs.extract-project.outputs.version }}
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
storage:
|
||||
needs: extract-project
|
||||
if: needs.extract-project.outputs.project == 'storage'
|
||||
uses: ./.github/workflows/storage_wf_release.yaml
|
||||
uses: ./.github/workflows/storage_release.yaml
|
||||
with:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
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:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -74,7 +70,7 @@ jobs:
|
||||
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
|
||||
|
||||
test_cli_build:
|
||||
uses: ./.github/workflows/cli_wf_test_new_project.yaml
|
||||
uses: ./.github/workflows/cli_test_new_project.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
- build_artifacts
|
||||
|
||||
4
.github/workflows/codegen_checks.yaml
vendored
4
.github/workflows/codegen_checks.yaml
vendored
@@ -25,10 +25,6 @@ on:
|
||||
branches:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
8
.github/workflows/dashboard_checks.yaml
vendored
8
.github/workflows/dashboard_checks.yaml
vendored
@@ -32,10 +32,6 @@ on:
|
||||
branches:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -63,7 +59,6 @@ jobs:
|
||||
VERCEL_TEAM_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||
VERCEL_DEPLOY_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
|
||||
|
||||
build_artifacts:
|
||||
@@ -76,7 +71,6 @@ jobs:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||
DOCKER: true
|
||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
@@ -99,7 +93,7 @@ jobs:
|
||||
|
||||
|
||||
e2e_staging:
|
||||
uses: ./.github/workflows/dashboard_wf_e2e_staging.yaml
|
||||
uses: ./.github/workflows/wf_dashboard_e2e_staging.yaml
|
||||
needs:
|
||||
- check-permissions
|
||||
- deploy-vercel
|
||||
|
||||
@@ -46,7 +46,6 @@ jobs:
|
||||
VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_DEPLOY_TOKEN: ${{ secrets.VERCEL_DEPLOY_TOKEN }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
build_artifacts:
|
||||
27
.github/workflows/dashboard_release_staging.yaml
vendored
27
.github/workflows/dashboard_release_staging.yaml
vendored
@@ -4,32 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- 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:
|
||||
deploy-vercel:
|
||||
@@ -45,5 +19,4 @@ jobs:
|
||||
VERCEL_TEAM_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||
VERCEL_DEPLOY_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
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:
|
||||
- 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:
|
||||
check-permissions:
|
||||
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:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -81,7 +77,6 @@ jobs:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||
DOCKER: false
|
||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
|
||||
@@ -41,10 +41,6 @@ on:
|
||||
branches:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -81,7 +77,6 @@ jobs:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||
DOCKER: false
|
||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
|
||||
@@ -41,10 +41,6 @@ on:
|
||||
branches:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -81,7 +77,6 @@ jobs:
|
||||
GIT_REF: ${{ github.sha }}
|
||||
VERSION: 0.0.0-dev # we use a fixed version here to avoid unnecessary rebuilds
|
||||
DOCKER: false
|
||||
OS_MATRIX: '["blacksmith-2vcpu-ubuntu-2404"]'
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID: ${{ secrets.AWS_PRODUCTION_CORE_ACCOUNT_ID }}
|
||||
NIX_CACHE_PUB_KEY: ${{ secrets.NIX_CACHE_PUB_KEY }}
|
||||
|
||||
10
.github/workflows/gen_codeql-analysis.yml
vendored
10
.github/workflows/gen_codeql-analysis.yml
vendored
@@ -1,6 +1,8 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push: {}
|
||||
pull_request: {}
|
||||
schedule:
|
||||
- cron: '20 23 * * 3'
|
||||
|
||||
@@ -16,7 +18,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript', 'go' ]
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
|
||||
@@ -26,7 +28,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
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.
|
||||
@@ -37,7 +39,7 @@ jobs:
|
||||
# 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@v3
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
@@ -51,4 +53,4 @@ jobs:
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@v2
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
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@v34
|
||||
- uses: nixbuild/nix-quick-install-action@v26
|
||||
with:
|
||||
nix_version: 2.16.2
|
||||
nix_conf: |
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@v6
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/nixops_checks.yaml
vendored
4
.github/workflows/nixops_checks.yaml
vendored
@@ -17,10 +17,6 @@ on:
|
||||
branches:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
4
.github/workflows/storage_checks.yaml
vendored
4
.github/workflows/storage_checks.yaml
vendored
@@ -26,10 +26,6 @@ on:
|
||||
branches:
|
||||
- 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:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
6
.github/workflows/wf_build_artifacts.yaml
vendored
6
.github/workflows/wf_build_artifacts.yaml
vendored
@@ -17,10 +17,6 @@ on:
|
||||
DOCKER:
|
||||
type: boolean
|
||||
required: true
|
||||
OS_MATRIX:
|
||||
type: string
|
||||
required: false
|
||||
default: '["blacksmith-4vcpu-ubuntu-2404-arm", "blacksmith-2vcpu-ubuntu-2404"]'
|
||||
secrets:
|
||||
AWS_ACCOUNT_ID:
|
||||
required: true
|
||||
@@ -41,7 +37,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: ${{ fromJSON(inputs.OS_MATRIX) }}
|
||||
os: [blacksmith-4vcpu-ubuntu-2404-arm, blacksmith-2vcpu-ubuntu-2404]
|
||||
fail-fast: true
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -59,10 +59,6 @@ on:
|
||||
PLAYWRIGHT_REPORT_ENCRYPTION_KEY:
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: dashboard-e2e-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
4
.github/workflows/wf_deploy_vercel.yaml
vendored
4
.github/workflows/wf_deploy_vercel.yaml
vendored
@@ -27,8 +27,6 @@ on:
|
||||
required: true
|
||||
DISCORD_WEBHOOK:
|
||||
required: false
|
||||
TURBO_TOKEN:
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
preview-url:
|
||||
@@ -71,8 +69,6 @@ jobs:
|
||||
env:
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: nhost
|
||||
run: |
|
||||
TARGET_OPTS="--target=${{ 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:
|
||||
- name: "Check out repository"
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
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
|
||||
@@ -9,7 +9,6 @@ module.exports = {
|
||||
project: './tsconfig.json',
|
||||
},
|
||||
ignorePatterns: [
|
||||
'src/utils/hasura-api/generated/',
|
||||
'**/.eslintrc.js',
|
||||
'**/prettier.config.js',
|
||||
'**/next.config.js',
|
||||
|
||||
@@ -1,20 +1,3 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [@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
|
||||
|
||||
## 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!;
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,6 @@
|
||||
"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-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=.",
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
@@ -60,7 +59,6 @@
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@segment/analytics-next": "^1.77.0",
|
||||
@@ -196,7 +194,6 @@
|
||||
"msw": "^1.3.5",
|
||||
"msw-storybook-addon": "^1.10.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"orval": "^7.11.2",
|
||||
"postcss": "^8.4.38",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
|
||||
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');
|
||||
}, [isPlatform, router]);
|
||||
|
||||
if (
|
||||
(isPlatform && isLoading) ||
|
||||
isSigningOut ||
|
||||
(isPlatform && !isAuthenticated)
|
||||
) {
|
||||
if ((isPlatform && isLoading) || isSigningOut) {
|
||||
return (
|
||||
<BaseLayout className="h-full" {...props}>
|
||||
<Header className="flex max-h-[59px] flex-auto py-1" />
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { useRouter } from 'next/router';
|
||||
import OrgPagesComboBox from './OrgPagesComboBox';
|
||||
import OrgsComboBox from './OrgsComboBox';
|
||||
import ProjectGraphQLPagesComboBox from './ProjectGraphQLPagesComboBox';
|
||||
import ProjectPagesComboBox from './ProjectPagesComboBox';
|
||||
import ProjectsComboBox from './ProjectsComboBox';
|
||||
import ProjectSettingsPagesComboBox from './ProjectSettingsPagesComboBox';
|
||||
@@ -26,7 +25,6 @@ export default function BreadcrumbNav() {
|
||||
// Identify project and settings pages based on the URL pattern
|
||||
const projectPage = pathSegments[3] || null;
|
||||
const isSettingsPage = pathSegments[5] === 'settings';
|
||||
const isGraphQLPage = pathSegments[5] === 'graphql';
|
||||
|
||||
const showBreadcrumbs = !['/', '/orgs/verify'].includes(route);
|
||||
|
||||
@@ -83,21 +81,6 @@ export default function BreadcrumbNav() {
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isGraphQLPage && (
|
||||
<>
|
||||
<BreadcrumbSeparator>
|
||||
<Slash
|
||||
strokeWidth={3.5}
|
||||
className="text-muted-foreground/50"
|
||||
/>
|
||||
</BreadcrumbSeparator>
|
||||
|
||||
<BreadcrumbItem>
|
||||
<ProjectGraphQLPagesComboBox />
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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' },
|
||||
];
|
||||
|
||||
const projectGraphQLPages = [
|
||||
{
|
||||
name: 'Playground',
|
||||
slug: 'playground',
|
||||
route: 'graphql',
|
||||
},
|
||||
{
|
||||
name: 'Remote Schemas',
|
||||
slug: 'remote-schemas',
|
||||
route: 'graphql/remote-schemas',
|
||||
},
|
||||
];
|
||||
|
||||
const createOrganization = (org: Org) => {
|
||||
const isNotPlatform = !getIsPlatform();
|
||||
const configServerVariableNotSet = getConfigServerUrl() === '';
|
||||
const shouldDisableSettings = isNotPlatform && configServerVariableNotSet;
|
||||
const shouldDisableGraphite = shouldDisableSettings;
|
||||
|
||||
const result = {};
|
||||
|
||||
result[org.slug] = {
|
||||
@@ -255,22 +243,13 @@ const createOrganization = (org: Org) => {
|
||||
result[`${org.slug}-${_app.subdomain}-${_page.slug}`] = {
|
||||
index: `${org.slug}-${_app.subdomain}-${_page.slug}`,
|
||||
canMove: false,
|
||||
isFolder:
|
||||
(_page.name === 'Settings' && !shouldDisableSettings) ||
|
||||
_page.name === 'GraphQL',
|
||||
children: (() => {
|
||||
if (_page.name === 'Settings' && !shouldDisableSettings) {
|
||||
return projectSettingsPages.map(
|
||||
(p) => `${org.slug}-${_app.subdomain}-settings-${p.slug}`,
|
||||
);
|
||||
}
|
||||
if (_page.name === 'GraphQL') {
|
||||
return projectGraphQLPages.map(
|
||||
(p) => `${org.slug}-${_app.subdomain}-graphql-${p.slug}`,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
})(),
|
||||
isFolder: _page.name === 'Settings' && !shouldDisableSettings,
|
||||
children:
|
||||
_page.name === 'Settings' && !shouldDisableSettings
|
||||
? projectSettingsPages.map(
|
||||
(p) => `${org.slug}-${_app.subdomain}-settings-${p.slug}`,
|
||||
)
|
||||
: undefined,
|
||||
data: {
|
||||
name: _page.name,
|
||||
icon: _page.icon,
|
||||
@@ -306,20 +285,6 @@ const createOrganization = (org: Org) => {
|
||||
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`] = {
|
||||
@@ -471,12 +436,6 @@ export default function NavTree() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.data.name === 'GraphQL' && item.isFolder) {
|
||||
if (!context.isExpanded) {
|
||||
context.toggleExpandedState();
|
||||
}
|
||||
}
|
||||
|
||||
if (item.data.type !== 'org') {
|
||||
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 };
|
||||
@@ -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 { Input } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { baseColumnValidationSchema } from '@/features/orgs/projects/database/dataGrid/components/BaseColumnForm';
|
||||
import type {
|
||||
DatabaseTable,
|
||||
ForeignKeyRelation,
|
||||
@@ -50,23 +51,6 @@ export interface BaseTableFormProps extends DialogFormProps {
|
||||
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({
|
||||
name: Yup.string()
|
||||
.required('This field is required.')
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
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 { DataBrowserEmptyState } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState';
|
||||
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 type { UpdateRecordVariables } 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_JSON_TYPES,
|
||||
} 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 { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
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 { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
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(
|
||||
() =>
|
||||
import(
|
||||
@@ -52,7 +63,7 @@ export function createDataGridColumn(
|
||||
const defaultColumnConfiguration = {
|
||||
Header: () => (
|
||||
<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}>
|
||||
{column.column_name}
|
||||
@@ -156,15 +167,23 @@ export default function DataBrowserGrid({
|
||||
...router
|
||||
} = useRouter();
|
||||
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 [currentOffset, setCurrentOffset] = useState<number>(
|
||||
parseInt(page as string, 10) - 1 || 0,
|
||||
);
|
||||
|
||||
const [removableColumnId, setRemovableColumnId] = useState<string>();
|
||||
const [optimisticlyRemovedColumnId, setOptimisticlyRemovedColumnId] =
|
||||
useState<string>();
|
||||
|
||||
const { mutateAsync: updateRow } = useUpdateRecordWithToastMutation();
|
||||
const { mutateAsync: deleteColumn } = useDeleteColumnWithToastMutation();
|
||||
|
||||
const { data, status, error, refetch } = useTableQuery(
|
||||
[currentTablePath, limit, currentOffset, sortBy],
|
||||
@@ -252,16 +271,26 @@ export default function DataBrowserGrid({
|
||||
|
||||
const memoizedColumns = useMemo(
|
||||
() =>
|
||||
columns.map((column) => ({
|
||||
...createDataGridColumn(column, true),
|
||||
onCellEdit: async (variables: UpdateRecordVariables) => {
|
||||
const result = await updateRow(variables);
|
||||
await queryClient.invalidateQueries([currentTablePath]);
|
||||
columns
|
||||
.map((column) => ({
|
||||
...createDataGridColumn(column, true),
|
||||
onCellEdit: async (variables: UpdateRecordVariables) => {
|
||||
const result = await updateRow(variables);
|
||||
await queryClient.invalidateQueries([currentTablePath]);
|
||||
|
||||
return result;
|
||||
},
|
||||
})),
|
||||
[columns, currentTablePath, queryClient, updateRow],
|
||||
return result;
|
||||
},
|
||||
isDisabled: removableColumnId === column.column_name,
|
||||
}))
|
||||
.filter((column) => column.id !== optimisticlyRemovedColumnId),
|
||||
[
|
||||
columns,
|
||||
currentTablePath,
|
||||
optimisticlyRemovedColumnId,
|
||||
queryClient,
|
||||
removableColumnId,
|
||||
updateRow,
|
||||
],
|
||||
);
|
||||
|
||||
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) {
|
||||
return (
|
||||
<DataBrowserEmptyState
|
||||
@@ -333,6 +414,8 @@ export default function DataBrowserGrid({
|
||||
sortBy={sortBy}
|
||||
className="pb-17 sm:pb-0"
|
||||
onInsertRow={handleInsertRowClick}
|
||||
onEditColumn={isSchemaEditable ? handleEditColumnClick : undefined}
|
||||
onRemoveColumn={isSchemaEditable ? handleColumnRemoveClick : undefined}
|
||||
options={{
|
||||
manualSortBy: true,
|
||||
disableMultiSort: true,
|
||||
@@ -340,6 +423,12 @@ export default function DataBrowserGrid({
|
||||
autoResetSelectedRows: false,
|
||||
autoResetResize: false,
|
||||
}}
|
||||
headerProps={{
|
||||
componentsProps: {
|
||||
editActionProps: { disabled: isGitHubConnected },
|
||||
deleteActionProps: { disabled: isGitHubConnected },
|
||||
},
|
||||
}}
|
||||
controls={
|
||||
<DataBrowserGridControls
|
||||
onInsertRowClick={handleInsertRowClick}
|
||||
|
||||
@@ -603,7 +603,7 @@ export default function DataBrowserSidebar({
|
||||
</Box>
|
||||
|
||||
<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}
|
||||
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 settingsPage = isSettingsPage ? pathSegments[6] || null : null;
|
||||
|
||||
const isGraphQLPage = pathSegments.includes('graphql');
|
||||
const graphqlSubPage =
|
||||
isGraphQLPage && pathSegments.length > 6 ? pathSegments[6] || null : null;
|
||||
|
||||
return useMemo(() => {
|
||||
if (!orgSlug) {
|
||||
// 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 };
|
||||
}, [
|
||||
orgSlug,
|
||||
@@ -88,8 +75,6 @@ const useNavTreeStateFromURL = (): TreeState => {
|
||||
projectPage,
|
||||
settingsPage,
|
||||
isSettingsPage,
|
||||
isGraphQLPage,
|
||||
graphqlSubPage,
|
||||
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 schemaEnvironment variables and secrets are available using the {{VARIABLE}} tag. Environment variable templating is available for this field. 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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +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 { RemoteSchemaRelationshipFormValues } from './RemoteSchemaRelationshipForm';
|
||||
|
||||
export interface TargetRemoteSchemaFieldComboboxProps {
|
||||
disabled?: boolean;
|
||||
targetFields: { label: string; value: string }[];
|
||||
}
|
||||
|
||||
export default function TargetRemoteSchemaFieldCombobox({
|
||||
disabled,
|
||||
targetFields,
|
||||
}: TargetRemoteSchemaFieldComboboxProps) {
|
||||
const form = useFormContext<RemoteSchemaRelationshipFormValues>();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="targetField"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-1 flex-col">
|
||||
<FormLabel>Target Remote Schema Field</FormLabel>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
!field.value && 'text-muted-foreground',
|
||||
{ 'border-destructive': form.formState.errors.targetField },
|
||||
)}
|
||||
>
|
||||
{field.value
|
||||
? targetFields.find(
|
||||
(fieldItem) => fieldItem.value === field.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 target field..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No target field found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{targetFields.map((fieldItem) => (
|
||||
<CommandItem
|
||||
value={fieldItem.label}
|
||||
key={fieldItem.value}
|
||||
onSelect={() => {
|
||||
form.setValue('targetField', fieldItem.value, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{fieldItem.label}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
fieldItem.value === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +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 { useDatabaseQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useDatabaseQuery';
|
||||
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 TargetTableComboboxProps {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function TargetTableCombobox({
|
||||
disabled,
|
||||
}: TargetTableComboboxProps) {
|
||||
const form = useFormContext<DatabaseRelationshipFormValues>();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const { data } = useDatabaseQuery(['default'], {
|
||||
dataSource: 'default',
|
||||
});
|
||||
|
||||
const handleSelectTable = (table: { name: string; schema: string }) => {
|
||||
form.setValue('table', table, {
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
|
||||
if (form.getValues('fieldMapping').length > 0) {
|
||||
form.setValue(
|
||||
'fieldMapping',
|
||||
form.getValues('fieldMapping').map((field) => ({
|
||||
...field,
|
||||
referenceColumn: '',
|
||||
})),
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const tables = (data?.tables ?? [])
|
||||
.flatMap((table) =>
|
||||
table.table_name && table.table_schema
|
||||
? [
|
||||
{
|
||||
label: `default / ${table.table_schema} / ${table.table_name}`,
|
||||
value: {
|
||||
name: table.table_name,
|
||||
schema: table.table_schema,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="table"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-1 flex-col">
|
||||
<FormLabel>Target Table</FormLabel>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'w-full justify-between',
|
||||
(!field.value?.name || !field.value?.schema) &&
|
||||
'text-muted-foreground',
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{field.value?.name && field.value?.schema
|
||||
? tables.find(
|
||||
(table) =>
|
||||
table.value.name === field.value?.name &&
|
||||
table.value.schema === field.value?.schema,
|
||||
)?.label
|
||||
: 'Select table'}
|
||||
<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 target table..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No target table found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
value={table.label}
|
||||
key={`${table.value.schema}/${table.value.name}`}
|
||||
onSelect={() => handleSelectTable(table.value)}
|
||||
>
|
||||
{table.label}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
table.value.name === field.value?.name &&
|
||||
table.value.schema === field.value?.schema
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
import BaseRemoteSchemaForm, {
|
||||
type BaseRemoteSchemaFormProps,
|
||||
type BaseRemoteSchemaFormValues,
|
||||
baseRemoteSchemaValidationSchema,
|
||||
} from '@/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/BaseRemoteSchemaForm';
|
||||
import { useCreateRemoteSchemaMutation } from '@/features/orgs/projects/remote-schemas/hooks/useCreateRemoteSchemaMutation';
|
||||
import { DEFAULT_REMOTE_SCHEMA_TIMEOUT_SECONDS } from '@/features/orgs/projects/remote-schemas/utils/constants';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type {
|
||||
AddRemoteSchemaArgs,
|
||||
Headers,
|
||||
} from '@/utils/hasura-api/generated/schemas';
|
||||
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 CreateRemoteSchemaFormProps
|
||||
extends Pick<BaseRemoteSchemaFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: (args?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function CreateRemoteSchemaForm({
|
||||
onSubmit,
|
||||
...props
|
||||
}: CreateRemoteSchemaFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { mutateAsync: createRemoteSchema } = useCreateRemoteSchemaMutation();
|
||||
|
||||
const form = useForm<
|
||||
| BaseRemoteSchemaFormValues
|
||||
| Yup.InferType<typeof baseRemoteSchemaValidationSchema>
|
||||
>({
|
||||
defaultValues: {
|
||||
name: '',
|
||||
comment: '',
|
||||
definition: {
|
||||
url: '',
|
||||
forward_client_headers: false,
|
||||
headers: [],
|
||||
timeout_seconds: DEFAULT_REMOTE_SCHEMA_TIMEOUT_SECONDS,
|
||||
customization: {
|
||||
root_fields_namespace: '',
|
||||
type_prefix: '',
|
||||
type_suffix: '',
|
||||
query_root: {
|
||||
parent_type: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
mutation_root: {
|
||||
parent_type: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldUnregister: true,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseRemoteSchemaValidationSchema),
|
||||
});
|
||||
|
||||
async function handleSubmit(values: BaseRemoteSchemaFormValues) {
|
||||
const headers: Headers = values.definition.headers
|
||||
?.map((header) => {
|
||||
if (header.value_from_env) {
|
||||
return {
|
||||
name: header.name,
|
||||
value_from_env: header.value_from_env,
|
||||
};
|
||||
}
|
||||
if (header.value) {
|
||||
return {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as Headers;
|
||||
|
||||
const remoteSchema: AddRemoteSchemaArgs = {
|
||||
name: values.name,
|
||||
comment: values.comment,
|
||||
definition: {
|
||||
...values.definition,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
try {
|
||||
await createRemoteSchema({ args: remoteSchema });
|
||||
await onSubmit?.();
|
||||
|
||||
await router.push(
|
||||
`/orgs/${router.query.orgSlug}/projects/${router.query.appSubdomain}/graphql/remote-schemas/${values.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'invalid-configuration') {
|
||||
throw new Error('cannot continue due to new inconsistent metadata');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Creating remote schema...',
|
||||
successMessage: 'The remote schema has been created successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while creating the remote schema. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BaseRemoteSchemaForm
|
||||
submitButtonText="Create"
|
||||
onSubmit={handleSubmit}
|
||||
{...props}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './CreateRemoteSchemaForm';
|
||||
export { default as CreateRemoteSchemaForm } from './CreateRemoteSchemaForm';
|
||||
@@ -1,81 +0,0 @@
|
||||
import BaseRemoteSchemaRelationshipForm from '@/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaRelationshipForm/BaseRemoteSchemaRelationshipForm';
|
||||
import type { DatabaseRelationshipFormValues } from '@/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaRelationshipForm/sections/DatabaseRelationshipForm';
|
||||
import type { RemoteSchemaRelationshipFormValues } from '@/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaRelationshipForm/sections/RemoteSchemaRelationshipForm';
|
||||
import { useCreateRemoteSchemaRelationshipMutation } from '@/features/orgs/projects/remote-schemas/hooks/useCreateRemoteSchemaRelationshipMutation';
|
||||
import {
|
||||
getDatabaseRelationshipPayload,
|
||||
getRemoteSchemaRelationshipPayload,
|
||||
} from '@/features/orgs/projects/remote-schemas/utils/forms';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
|
||||
export interface CreateRemoteSchemaRelationshipFormProps {
|
||||
/**
|
||||
* The schema name of the remote schema that is being edited.
|
||||
*/
|
||||
schema: string;
|
||||
onSubmit?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export default function CreateRemoteSchemaRelationshipForm({
|
||||
schema,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: CreateRemoteSchemaRelationshipFormProps) {
|
||||
const { mutateAsync: createRemoteSchemaRelationship } =
|
||||
useCreateRemoteSchemaRelationshipMutation();
|
||||
|
||||
const handleDatabaseRelationshipCreate = async (
|
||||
values: DatabaseRelationshipFormValues,
|
||||
) =>
|
||||
execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const args = getDatabaseRelationshipPayload(values);
|
||||
await createRemoteSchemaRelationship({ args });
|
||||
onSubmit?.();
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Creating database relationship...',
|
||||
successMessage:
|
||||
'The database relationship has been created successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while creating the database relationship. Please try again.',
|
||||
},
|
||||
);
|
||||
|
||||
const handleRemoteSchemaRelationshipCreate = async (
|
||||
values: RemoteSchemaRelationshipFormValues,
|
||||
) =>
|
||||
execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const args = getRemoteSchemaRelationshipPayload(values);
|
||||
await createRemoteSchemaRelationship({ args });
|
||||
onSubmit?.();
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Creating remote schema relationship...',
|
||||
successMessage:
|
||||
'The remote schema relationship has been created successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while creating the remote schema relationship. Please try again.',
|
||||
},
|
||||
);
|
||||
|
||||
const handleSubmit = (
|
||||
values: DatabaseRelationshipFormValues | RemoteSchemaRelationshipFormValues,
|
||||
) => {
|
||||
if ('table' in values) {
|
||||
return handleDatabaseRelationshipCreate(values);
|
||||
}
|
||||
return handleRemoteSchemaRelationshipCreate(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseRemoteSchemaRelationshipForm
|
||||
schema={schema}
|
||||
onCancel={onCancel}
|
||||
onSubmit={handleSubmit}
|
||||
submitButtonText="Create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as CreateRemoteSchemaRelationshipForm } from './CreateRemoteSchemaRelationshipForm';
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useGetMetadataResourceVersion } from '@/features/orgs/projects/common/hooks/useGetMetadataResourceVersion';
|
||||
import BaseRemoteSchemaForm, {
|
||||
type BaseRemoteSchemaFormProps,
|
||||
type BaseRemoteSchemaFormValues,
|
||||
baseRemoteSchemaValidationSchema,
|
||||
} from '@/features/orgs/projects/remote-schemas/components/BaseRemoteSchemaForm/BaseRemoteSchemaForm';
|
||||
import { useUpdateRemoteSchemaMutation } from '@/features/orgs/projects/remote-schemas/hooks/useUpdateRemoteSchemaMutation';
|
||||
import { DEFAULT_REMOTE_SCHEMA_TIMEOUT_SECONDS } from '@/features/orgs/projects/remote-schemas/utils/constants';
|
||||
import { isRemoteSchemaFromUrlDefinition } from '@/features/orgs/projects/remote-schemas/utils/guards';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type {
|
||||
Headers,
|
||||
RemoteSchemaInfo,
|
||||
} from '@/utils/hasura-api/generated/schemas';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useRouter } from 'next/router';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import type * as Yup from 'yup';
|
||||
import EditGraphQLCustomizations from './sections/EditGraphQLCustomizations';
|
||||
|
||||
export interface EditRemoteSchemaFormProps
|
||||
extends Pick<BaseRemoteSchemaFormProps, 'onCancel' | 'location'> {
|
||||
/**
|
||||
* Remote schema to be edited.
|
||||
*/
|
||||
originalSchema: RemoteSchemaInfo;
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: (args?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function EditRemoteSchemaForm({
|
||||
originalSchema,
|
||||
onSubmit,
|
||||
...props
|
||||
}: EditRemoteSchemaFormProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const { data: resourceVersion } = useGetMetadataResourceVersion();
|
||||
|
||||
const { mutateAsync: updateRemoteSchema } = useUpdateRemoteSchemaMutation();
|
||||
|
||||
const form = useForm<
|
||||
| BaseRemoteSchemaFormValues
|
||||
| Yup.InferType<typeof baseRemoteSchemaValidationSchema>
|
||||
>({
|
||||
defaultValues: {
|
||||
name: originalSchema.name,
|
||||
comment: originalSchema.comment,
|
||||
definition: {
|
||||
url: isRemoteSchemaFromUrlDefinition(originalSchema.definition)
|
||||
? originalSchema.definition.url
|
||||
: originalSchema.definition.url_from_env,
|
||||
forward_client_headers:
|
||||
originalSchema.definition.forward_client_headers ?? false,
|
||||
headers: originalSchema.definition.headers ?? [],
|
||||
timeout_seconds:
|
||||
originalSchema.definition.timeout_seconds ??
|
||||
DEFAULT_REMOTE_SCHEMA_TIMEOUT_SECONDS,
|
||||
customization: originalSchema.definition?.customization ?? {
|
||||
root_fields_namespace: '',
|
||||
type_names: {},
|
||||
field_names: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
shouldUnregister: true,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(baseRemoteSchemaValidationSchema),
|
||||
});
|
||||
|
||||
async function handleSubmit(values: BaseRemoteSchemaFormValues) {
|
||||
const headers: Headers = values.definition.headers
|
||||
?.map((header) => {
|
||||
if (header.value_from_env) {
|
||||
return {
|
||||
name: header.name,
|
||||
value_from_env: header.value_from_env,
|
||||
};
|
||||
}
|
||||
if (header.value) {
|
||||
return {
|
||||
name: header.name,
|
||||
value: header.value,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean) as Headers;
|
||||
|
||||
const remoteSchema: RemoteSchemaInfo = {
|
||||
...originalSchema,
|
||||
name: values.name,
|
||||
comment: values.comment,
|
||||
definition: {
|
||||
...values.definition,
|
||||
headers,
|
||||
},
|
||||
};
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
try {
|
||||
await updateRemoteSchema({
|
||||
originalRemoteSchema: originalSchema,
|
||||
updatedRemoteSchema: remoteSchema,
|
||||
resourceVersion,
|
||||
});
|
||||
|
||||
await onSubmit?.();
|
||||
|
||||
await router.push(
|
||||
`/orgs/${router.query.orgSlug}/projects/${router.query.appSubdomain}/graphql/remote-schemas/${values.name}`,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error?.code === 'invalid-configuration') {
|
||||
throw new Error('cannot continue due to new inconsistent metadata');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Updating remote schema...',
|
||||
successMessage: 'The remote schema has been updated successfully.',
|
||||
errorMessage: 'An error occurred while updating the remote schema.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<BaseRemoteSchemaForm
|
||||
submitButtonText="Save"
|
||||
onSubmit={handleSubmit}
|
||||
nameInputDisabled
|
||||
graphQLCustomizationsSlot={
|
||||
<EditGraphQLCustomizations remoteSchemaName={originalSchema.name} />
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './EditRemoteSchemaForm';
|
||||
export { default as EditRemoteSchemaForm } from './EditRemoteSchemaForm';
|
||||
@@ -1,538 +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 { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import { Button as ButtonV3 } 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 useIntrospectRemoteSchemaQuery from '@/features/orgs/projects/remote-schemas/hooks/useIntrospectRemoteSchemaQuery/useIntrospectRemoteSchemaQuery';
|
||||
import isStandardGraphQLScalar from '@/features/orgs/projects/remote-schemas/utils/isStandardGraphQLScalar';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
GraphQLTypeForVisualization,
|
||||
GraphQLTypeForVisualizationFieldsItem,
|
||||
RemoteSchemaCustomizationFieldNamesItem,
|
||||
} from '@/utils/hasura-api/generated/schemas';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Controller,
|
||||
useFieldArray,
|
||||
useFormContext,
|
||||
useWatch,
|
||||
} from 'react-hook-form';
|
||||
import TypeNameCustomizationCombobox from './TypeNameCustomizationCombobox';
|
||||
|
||||
export interface EditGraphQLCustomizationsProps {
|
||||
remoteSchemaName: string;
|
||||
}
|
||||
|
||||
export default function EditGraphQLCustomizations({
|
||||
remoteSchemaName,
|
||||
}: EditGraphQLCustomizationsProps) {
|
||||
const { data, isLoading, error } =
|
||||
useIntrospectRemoteSchemaQuery(remoteSchemaName);
|
||||
|
||||
const schemaTypes = useMemo(() => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const types = (data?.__schema?.types ??
|
||||
[]) as GraphQLTypeForVisualization[];
|
||||
return types.filter(
|
||||
(t) => Boolean(t?.name) && !isStandardGraphQLScalar(t.name!),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const { control, register, getValues, setValue, unregister } =
|
||||
useFormContext();
|
||||
|
||||
function getParentTypeFields(parentTypeName?: string) {
|
||||
const parentType = schemaTypes.find((t) => t.name === parentTypeName);
|
||||
return (parentType?.fields ??
|
||||
[]) as GraphQLTypeForVisualizationFieldsItem[];
|
||||
}
|
||||
|
||||
const {
|
||||
fields: fieldArrayFields,
|
||||
append: appendFieldName,
|
||||
remove: removeFieldName,
|
||||
} = useFieldArray({
|
||||
control,
|
||||
name: 'definition.customization.field_names',
|
||||
});
|
||||
|
||||
const rawTypeNamesMapping = useWatch({
|
||||
name: 'definition.customization.type_names.mapping',
|
||||
});
|
||||
const rawFieldNames = useWatch({
|
||||
name: 'definition.customization.field_names',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const isObjectMapping =
|
||||
rawTypeNamesMapping &&
|
||||
typeof rawTypeNamesMapping === 'object' &&
|
||||
!Array.isArray(rawTypeNamesMapping);
|
||||
if (!isObjectMapping) {
|
||||
setValue(
|
||||
'definition.customization.type_names.mapping',
|
||||
{},
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [rawTypeNamesMapping, setValue]);
|
||||
|
||||
const existingFieldNames: RemoteSchemaCustomizationFieldNamesItem[] =
|
||||
Array.isArray(rawFieldNames)
|
||||
? (rawFieldNames as RemoteSchemaCustomizationFieldNamesItem[])
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!Array.isArray(rawFieldNames)) {
|
||||
setValue('definition.customization.field_names', [], {
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
});
|
||||
}
|
||||
}, [rawFieldNames, setValue]);
|
||||
|
||||
const typeNamesMapping: Record<string, string> = useMemo(() => {
|
||||
if (
|
||||
rawTypeNamesMapping &&
|
||||
typeof rawTypeNamesMapping === 'object' &&
|
||||
!Array.isArray(rawTypeNamesMapping)
|
||||
) {
|
||||
return rawTypeNamesMapping as Record<string, string>;
|
||||
}
|
||||
return {};
|
||||
}, [rawTypeNamesMapping]);
|
||||
|
||||
const canAddTypeRemap = useMemo(
|
||||
() =>
|
||||
schemaTypes.some(
|
||||
(t) =>
|
||||
t?.name && !(typeNamesMapping as Record<string, string>)?.[t.name!],
|
||||
),
|
||||
[schemaTypes, typeNamesMapping],
|
||||
);
|
||||
|
||||
function addFirstAvailableTypeRemap() {
|
||||
const current = (getValues('definition.customization.type_names.mapping') ??
|
||||
{}) as Record<string, string>;
|
||||
const used = new Set(Object.keys(current));
|
||||
const candidate = schemaTypes.find((t) => t?.name && !used.has(t.name!));
|
||||
if (candidate?.name) {
|
||||
setValue(
|
||||
`definition.customization.type_names.mapping.${candidate.name}`,
|
||||
'',
|
||||
{ shouldDirty: true, shouldTouch: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function changeTypeKey(oldKey: string, newKey: string) {
|
||||
if (!newKey || oldKey === newKey) {
|
||||
return;
|
||||
}
|
||||
const oldPath = `definition.customization.type_names.mapping.${oldKey}`;
|
||||
const newPath = `definition.customization.type_names.mapping.${newKey}`;
|
||||
const value = getValues(oldPath) ?? '';
|
||||
setValue(newPath, value, { shouldDirty: true, shouldTouch: true });
|
||||
unregister(oldPath);
|
||||
}
|
||||
|
||||
function removeTypeRemap(key: string) {
|
||||
const path = `definition.customization.type_names.mapping.${key}`;
|
||||
unregister(path);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className="space-y-2">
|
||||
<Text variant="h4" className="text-lg font-semibold">
|
||||
GraphQL Customizations
|
||||
</Text>
|
||||
<Text variant="body2" color="secondary" className="text-sm">
|
||||
Introspecting remote schema...
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box className="space-y-2">
|
||||
<Text variant="h4" className="text-lg font-semibold">
|
||||
GraphQL Customizations
|
||||
</Text>
|
||||
<Text variant="body2" color="error" className="text-sm">
|
||||
Failed to introspect remote schema. Type/field mapping is unavailable.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="space-y-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="text-lg font-semibold">
|
||||
GraphQL Customizations
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-4 rounded border-1 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_names.prefix', {
|
||||
setValueAs: (v) =>
|
||||
typeof v === 'string' && v.trim() === '' ? undefined : v,
|
||||
})}
|
||||
id="definition.customization.type_names.prefix"
|
||||
name="definition.customization.type_names.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_names.suffix', {
|
||||
setValueAs: (v) =>
|
||||
typeof v === 'string' && v.trim() === '' ? undefined : v,
|
||||
})}
|
||||
id="definition.customization.type_names.suffix"
|
||||
name="definition.customization.type_names.suffix"
|
||||
placeholder="_suffix"
|
||||
hideEmptyHelperText
|
||||
autoComplete="off"
|
||||
variant="inline"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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="text-lg font-semibold">
|
||||
Rename Type Names
|
||||
</Text>
|
||||
<Tooltip title="Type remapping takes precedence to prefixes and suffixes.">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={addFirstAvailableTypeRemap}
|
||||
disabled={!canAddTypeRemap}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-3">
|
||||
{Object.entries(typeNamesMapping).map(([fromType]) => (
|
||||
<Box key={fromType} className="rounded border p-3">
|
||||
<Box className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<TypeNameCustomizationCombobox
|
||||
fromType={fromType}
|
||||
schemaTypes={schemaTypes}
|
||||
changeTypeKey={changeTypeKey}
|
||||
/>
|
||||
<Box>
|
||||
<Text className="font-medium">New name</Text>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`definition.customization.type_names.mapping.${fromType}`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="New type name"
|
||||
hideEmptyHelperText
|
||||
autoComplete="off"
|
||||
variant="inline"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box className="flex items-end justify-end md:justify-start">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="col-span-1"
|
||||
color="error"
|
||||
onClick={() => removeTypeRemap(fromType)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<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="text-lg font-semibold">
|
||||
Field Names
|
||||
</Text>
|
||||
<Tooltip title="Add mappings for fields of a selected parent type. You can also set a prefix/suffix for those fields.">
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() =>
|
||||
appendFieldName({} as RemoteSchemaCustomizationFieldNamesItem)
|
||||
}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box className="space-y-3">
|
||||
{fieldArrayFields?.map((row, index) => {
|
||||
const parentTypeValue =
|
||||
(Array.isArray(rawFieldNames) &&
|
||||
(rawFieldNames as RemoteSchemaCustomizationFieldNamesItem[])?.[
|
||||
index
|
||||
]?.parent_type) ||
|
||||
(row as any)?.parent_type;
|
||||
const fields = getParentTypeFields(parentTypeValue);
|
||||
return (
|
||||
<Box key={row.id ?? index} className="rounded border p-3">
|
||||
<Box className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<Box>
|
||||
<Text className="font-medium">Parent type</Text>
|
||||
<Controller
|
||||
name={`definition.customization.field_names.${index}.parent_type`}
|
||||
control={control}
|
||||
defaultValue={(row as any)?.parent_type}
|
||||
render={({ field }) => (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<ButtonV3
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn(
|
||||
'mt-1 w-full justify-between overflow-hidden text-left',
|
||||
!(field.value ?? '') && 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">
|
||||
{field.value ?? 'Select a type'}
|
||||
</span>
|
||||
<ChevronsUpDown className="ml-2 shrink-0 opacity-50" />
|
||||
</ButtonV3>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="Search type..."
|
||||
className="h-9"
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No type found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{schemaTypes
|
||||
.filter(
|
||||
(t) =>
|
||||
!(existingFieldNames ?? []).some(
|
||||
(i, iIndex) =>
|
||||
iIndex !== index &&
|
||||
i.parent_type === t.name,
|
||||
),
|
||||
)
|
||||
.map((t) => (
|
||||
<CommandItem
|
||||
value={t.name!}
|
||||
key={t.name!}
|
||||
onSelect={() => {
|
||||
unregister(
|
||||
`definition.customization.field_names.${index}.mapping`,
|
||||
);
|
||||
field.onChange(t.name!);
|
||||
setValue(
|
||||
`definition.customization.field_names.${index}.mapping`,
|
||||
{},
|
||||
{
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
},
|
||||
);
|
||||
}}
|
||||
className="flex items-center"
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{t.name}
|
||||
</span>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-2 shrink-0',
|
||||
t.name === field.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text className="font-medium">Field Prefix</Text>
|
||||
<Input
|
||||
{...register(
|
||||
`definition.customization.field_names.${index}.prefix`,
|
||||
{
|
||||
setValueAs: (v) =>
|
||||
typeof v === 'string' && v.trim() === ''
|
||||
? undefined
|
||||
: v,
|
||||
},
|
||||
)}
|
||||
defaultValue={(row as any)?.prefix ?? ''}
|
||||
placeholder="prefix_"
|
||||
hideEmptyHelperText
|
||||
autoComplete="off"
|
||||
className="mt-1"
|
||||
/>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text className="font-medium">Field Suffix</Text>
|
||||
<Input
|
||||
{...register(
|
||||
`definition.customization.field_names.${index}.suffix`,
|
||||
{
|
||||
setValueAs: (v) =>
|
||||
typeof v === 'string' && v.trim() === ''
|
||||
? undefined
|
||||
: v,
|
||||
},
|
||||
)}
|
||||
defaultValue={(row as any)?.suffix ?? ''}
|
||||
placeholder="_suffix"
|
||||
hideEmptyHelperText
|
||||
autoComplete="off"
|
||||
className="mt-1"
|
||||
variant="inline"
|
||||
fullWidth
|
||||
/>
|
||||
</Box>
|
||||
<Box className="flex items-end justify-end md:justify-start">
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="col-span-1"
|
||||
color="error"
|
||||
onClick={() => removeFieldName(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="mt-3 space-y-2">
|
||||
<Text className="font-medium">Field remaps</Text>
|
||||
<Box className="space-y-2">
|
||||
{fields.map((f) => {
|
||||
const key = f.name;
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
className="grid grid-cols-1 gap-2 md:grid-cols-2"
|
||||
>
|
||||
<Input
|
||||
disabled
|
||||
value={key}
|
||||
variant="inline"
|
||||
fullWidth
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name={`definition.customization.field_names.${index}.mapping.${key}`}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
value={field.value ?? ''}
|
||||
onChange={(e) => field.onChange(e.target.value)}
|
||||
placeholder="new_field_name"
|
||||
hideEmptyHelperText
|
||||
autoComplete="off"
|
||||
variant="inline"
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user