Compare commits

..

20 Commits

Author SHA1 Message Date
github-actions[bot]
565aee6d34 release(dashboard): 2.38.1 (#3535)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-09-30 13:21:54 +02:00
David Barroso
47013da462 chore(ci): validate PR title (#3537) 2025-09-30 13:20:02 +02:00
dependabot[bot]
2e701456d3 chore(ci): bump actions/checkout from 4 to 5 (#3526)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: David Barroso <dbarrosop@dravetech.com>
2025-09-30 11:41:42 +02:00
dependabot[bot]
f08bbc62f6 chore(ci): bump aws-actions/configure-aws-credentials from 4 to 5 (#3522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: David Barroso <dbarrosop@dravetech.com>
2025-09-30 11:40:09 +02:00
robertkasza
93c233deb0 fix (dashboard): delay generating auth service url when creating users (#3530) 2025-09-30 09:22:46 +02:00
David Barroso
ff2a84aa37 chore(ci): use variables in gen_ai_review workflow to configure models (#3534) 2025-09-30 08:45:56 +02:00
github-actions[bot]
3ca082d368 release(cli): 1.32.1 (#3533)
Co-authored-by: dbarrosop <dbarrosop@users.noreply.github.com>
2025-09-29 17:51:37 +02:00
David Barroso
cec16c6b89 chore(cli): update schema (#3529) 2025-09-29 17:48:59 +02:00
David Barroso
543f2c2b0e fix(nixops): export correctly (#3531) 2025-09-29 17:20:51 +02:00
David Barroso
53ac9263c1 fix(ci): specify base when bumping the dashboard in the cli (#3532) 2025-09-29 14:46:29 +02:00
github-actions[bot]
81b304a715 release(dashboard): 2.38.0 (#3503)
Co-authored-by: robertkasza <robertkasza@users.noreply.github.com>
2025-09-29 13:44:58 +02:00
robertkasza
ac9956bcdb feat(dashboard): datatable column header redesign (#3500) 2025-09-29 13:34:38 +02:00
David Barroso
7d2bc4c06e chore(ci): minor improvements to the ci (#3527) 2025-09-29 12:02:04 +02:00
David Barroso
48ef43202c feat(docs): added links to react-apollo and react-query guides (#3528) 2025-09-29 10:28:45 +02:00
David BM
e6ae494336 feat(dashboard): add remote schemas (#3299)
Co-authored-by: David Barroso Murcia <davidbm@air-m4.local>
Co-authored-by: David Barroso <dbarrosop@dravetech.com>
2025-09-26 16:14:34 +02:00
David Barroso
092e98358f chore(ci): implement storage releases (#3525) 2025-09-26 15:03:29 +02:00
dependabot[bot]
dd945daa1a chore(ci): bump nixbuild/nix-quick-install-action from 26 to 34 (#3524) 2025-09-26 13:52:23 +02:00
dependabot[bot]
0fe38e206b chore(ci): bump github/codeql-action from 2 to 3 (#3523) 2025-09-26 13:52:14 +02:00
dependabot[bot]
373657339c chore(ci): bump actions/checkout from 4 to 5 (#3521) 2025-09-26 13:52:02 +02:00
dependabot[bot]
3833158107 chore(ci): bump peter-evans/create-pull-request from 6 to 7 (#3520) 2025-09-26 13:51:55 +02:00
324 changed files with 15866 additions and 2169 deletions

View File

@@ -0,0 +1,41 @@
---
name: "Validate PR Title"
description: "Validates that PR title follows the required format: TYPE(PKG): SUMMARY"
inputs:
pr_title:
description: "The PR title to validate"
required: true
runs:
using: "composite"
steps:
- name: "Validate PR title format"
shell: bash
run: |
PR_TITLE="${{ inputs.pr_title }}"
echo "Validating PR title: $PR_TITLE"
# Define valid types and packages
VALID_TYPES="feat|fix|chore"
VALID_PKGS="ci|cli|codegen|dashboard|deps|docs|examples|mintlify-openapi|nhost-js|nixops|storage"
# Check if title matches the pattern TYPE(PKG): SUMMARY
if [[ ! "$PR_TITLE" =~ ^(${VALID_TYPES})\((${VALID_PKGS})\):\ .+ ]]; then
echo "❌ PR title does not follow the required format!"
echo ""
echo "Expected format: TYPE(PKG): SUMMARY"
echo ""
echo "Valid TYPEs:"
echo " - feat: mark this pull request as a feature"
echo " - fix: mark this pull request as a bug fix"
echo " - chore: mark this pull request as a maintenance item"
echo ""
echo "Valid PKGs:"
echo " - ci, cli, codegen, dashboard, deps, docs, examples,"
echo " - mintlify-openapi, nhost-js, nixops, storage"
echo ""
echo "Example: feat(cli): add new command for database migrations"
exit 1
fi
echo "✅ PR title is valid!"

View File

@@ -36,7 +36,7 @@ jobs:
cli:
needs: extract-project
if: needs.extract-project.outputs.project == 'cli'
uses: ./.github/workflows/cli_release.yaml
uses: ./.github/workflows/cli_wf_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_release.yaml
uses: ./.github/workflows/dashboard_wf_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_release.yaml
uses: ./.github/workflows/storage_wf_release.yaml
with:
GIT_REF: ${{ github.sha }}
VERSION: ${{ needs.extract-project.outputs.version }}

View File

@@ -27,6 +27,10 @@ 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
@@ -70,7 +74,7 @@ jobs:
NIX_CACHE_PRIV_KEY: ${{ secrets.NIX_CACHE_PRIV_KEY }}
test_cli_build:
uses: ./.github/workflows/cli_test_new_project.yaml
uses: ./.github/workflows/cli_wf_test_new_project.yaml
needs:
- check-permissions
- build_artifacts

View File

@@ -68,7 +68,7 @@ jobs:
ref: ${{ inputs.GIT_REF }}
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -50,7 +50,7 @@ jobs:
comment_on_pr: false
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -25,6 +25,10 @@ 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

View File

@@ -32,6 +32,10 @@ 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
@@ -59,6 +63,7 @@ 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:
@@ -71,6 +76,7 @@ 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 }}
@@ -93,7 +99,7 @@ jobs:
e2e_staging:
uses: ./.github/workflows/wf_dashboard_e2e_staging.yaml
uses: ./.github/workflows/dashboard_wf_e2e_staging.yaml
needs:
- check-permissions
- deploy-vercel

View File

@@ -4,6 +4,32 @@ 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:
@@ -19,4 +45,5 @@ 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 }}

View File

@@ -59,6 +59,10 @@ 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
@@ -101,7 +105,7 @@ jobs:
comment_on_pr: false
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -46,6 +46,7 @@ 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:
@@ -97,6 +98,7 @@ jobs:
committer: GitHub <noreply@github.com>
author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
body: |
This PR bumps the Nhost Dashboard Docker image to version ${{ needs.version.outputs.dashboardVersion }}.
This PR bumps the Nhost Dashboard Docker image to version ${{ inputs.VERSION }}.
branch: bump-dashboard-version
base: main
delete-branch: true

View File

@@ -31,6 +31,10 @@ 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

View File

@@ -41,6 +41,10 @@ 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
@@ -77,6 +81,7 @@ 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 }}

View File

@@ -41,6 +41,10 @@ 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
@@ -77,6 +81,7 @@ 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 }}

View File

@@ -41,6 +41,10 @@ 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
@@ -77,6 +81,7 @@ 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 }}

View File

@@ -21,6 +21,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
config.model: "anthropic/claude-sonnet-4-20250514"
config.model_turbo: "anthropic/claude-sonnet-4-20250514"
config.model: ${{ vars.GEN_AI_MODEL }}
config.model_turbo: $${{ vars.GEN_AI_MODEL_TURBO }}
ignore.glob: "['pnpm-lock.yaml','**/pnpm-lock.yaml', 'vendor/**','**/client_gen.go','**/models_gen.go','**/generated.go','**/*.gen.go']"

View File

@@ -1,8 +1,6 @@
name: "CodeQL"
on:
push: {}
pull_request: {}
schedule:
- cron: '20 23 * * 3'
@@ -18,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
language: [ 'javascript', 'go' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://git.io/codeql-language-support
@@ -28,7 +26,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -39,7 +37,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@v2
uses: github/codeql-action/autobuild@v3
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -53,4 +51,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
uses: github/codeql-action/analyze@v3

View File

@@ -18,12 +18,12 @@ jobs:
uses: actions/checkout@v5
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
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
- uses: nixbuild/nix-quick-install-action@v34
with:
nix_version: 2.16.2
nix_conf: |
@@ -51,7 +51,7 @@ jobs:
"
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: Update dependencies

View File

@@ -38,6 +38,10 @@ 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

View File

@@ -17,6 +17,10 @@ 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

View File

@@ -26,6 +26,10 @@ 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

View File

@@ -17,6 +17,10 @@ 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
@@ -37,7 +41,7 @@ jobs:
strategy:
matrix:
os: [blacksmith-4vcpu-ubuntu-2404-arm, blacksmith-2vcpu-ubuntu-2404]
os: ${{ fromJSON(inputs.OS_MATRIX) }}
fail-fast: true
runs-on: ${{ matrix.os }}
@@ -49,8 +53,14 @@ jobs:
with:
ref: ${{ inputs.GIT_REF }}
- name: "Validate PR title"
uses: ./.github/actions/validate-pr-title
with:
pr_title: ${{ github.event.pull_request.title }}
if: ${{ github.event_name == 'pull_request' }}
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -44,13 +44,19 @@ jobs:
with:
ref: ${{ inputs.GIT_REF }}
- name: "Validate PR title"
uses: ./.github/actions/validate-pr-title
with:
pr_title: ${{ github.event.pull_request.title }}
if: ${{ github.event_name == 'pull_request' }}
- name: Collect Workflow Telemetry
uses: catchpoint/workflow-telemetry-action@v2
with:
comment_on_pr: false
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -27,6 +27,8 @@ on:
required: true
DISCORD_WEBHOOK:
required: false
TURBO_TOKEN:
required: true
outputs:
preview-url:
@@ -52,7 +54,7 @@ jobs:
ref: ${{ inputs.GIT_REF }}
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1
@@ -69,6 +71,8 @@ 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 }}..."

View File

@@ -33,7 +33,7 @@ jobs:
steps:
- name: "Check out repository"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
submodules: true

View File

@@ -33,13 +33,13 @@ jobs:
steps:
- name: "Check out repository"
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
submodules: true
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -42,7 +42,7 @@ jobs:
uses: actions/checkout@v5
- name: Configure aws
uses: aws-actions/configure-aws-credentials@v4
uses: aws-actions/configure-aws-credentials@v5
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/github-actions-nhost-${{ github.event.repository.name }}
aws-region: eu-central-1

View File

@@ -1,20 +0,0 @@
## 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
View File

@@ -1,36 +0,0 @@
#!/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/

View File

@@ -1,8 +0,0 @@
labels:
'feature':
- '^(?i:feat)'
- '^(?i:feature)'
'fix':
- '^(?i:fix)'
'chore':
- '^(?i:chore)'

View File

@@ -1,39 +0,0 @@
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
View File

@@ -1,16 +0,0 @@
# 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.

View File

@@ -1,17 +0,0 @@
# 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}}

View File

@@ -1,53 +0,0 @@
---
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 }}

View File

@@ -1,27 +0,0 @@
---
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 }}

View File

@@ -1,56 +0,0 @@
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

View File

@@ -1,27 +0,0 @@
---
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']"

View File

@@ -1,91 +0,0 @@
---
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()

View File

@@ -1,35 +0,0 @@
---
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 }}

View File

@@ -1,17 +0,0 @@
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 }}

View File

@@ -1,89 +0,0 @@
---
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' ) }}

View File

@@ -1,42 +0,0 @@
---
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

View File

@@ -1,93 +0,0 @@
---
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

11
cli/CHANGELOG.md Normal file
View File

@@ -0,0 +1,11 @@
# Changelog
All notable changes to this project will be documented in this file.
## [cli@1.32.1] - 2025-09-29
### ⚙️ Miscellaneous Tasks
- *(ci)* Minor improvements to the ci (#3527)
- *(cli)* Update schema (#3529)

View File

@@ -9,6 +9,7 @@ module.exports = {
project: './tsconfig.json',
},
ignorePatterns: [
'src/utils/hasura-api/generated/',
'**/.eslintrc.js',
'**/prettier.config.js',
'**/next.config.js',

View File

@@ -1,3 +1,31 @@
# Changelog
All notable changes to this project will be documented in this file.
## [@nhost/dashboard@2.38.1] - 2025-09-30
### 🐛 Bug Fixes
- *(dashboard)* Delay generating auth service url when creating users (#3530)
### ⚙️ Miscellaneous Tasks
- *(ci)* Bump aws-actions/configure-aws-credentials from 4 to 5 (#3522)
## [@nhost/dashboard@2.38.0] - 2025-09-29
### 🚀 Features
- *(dashboard)* Add remote schemas (#3299)
- *(dashboard)* Datatable column header redesign (#3500)
### ⚙️ Miscellaneous Tasks
- *(ci)* Add e2e remote schema name repository variable to CI workflow (#3502)
- *(dashboard)* Fix defects in basetable form and add comment to columns (#3475)
# @nhost/dashboard
## 2.37.0

View File

@@ -44,3 +44,9 @@ 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!;

View File

@@ -0,0 +1,64 @@
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);
});
});

View File

@@ -10,9 +10,11 @@
"start": "next start",
"lint": "next lint --max-warnings 0",
"test": "vitest --run",
"test:watch": "vitest",
"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",
@@ -59,6 +61,7 @@
"@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",
@@ -194,6 +197,7 @@
"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",

View File

@@ -13,7 +13,7 @@ export default defineConfig({
},
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: 0,
retries: 2,
workers: 1,
reporter: 'html',
use: {

1816
dashboard/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -64,7 +64,11 @@ export default function AuthenticatedLayout({
router.push('/orgs/local/projects/local');
}, [isPlatform, router]);
if ((isPlatform && isLoading) || isSigningOut) {
if (
(isPlatform && isLoading) ||
isSigningOut ||
(isPlatform && !isAuthenticated)
) {
return (
<BaseLayout className="h-full" {...props}>
<Header className="flex max-h-[59px] flex-auto py-1" />

View File

@@ -10,6 +10,7 @@ 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';
@@ -25,6 +26,7 @@ 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);
@@ -81,6 +83,21 @@ export default function BreadcrumbNav() {
</BreadcrumbItem>
</>
)}
{isGraphQLPage && (
<>
<BreadcrumbSeparator>
<Slash
strokeWidth={3.5}
className="text-muted-foreground/50"
/>
</BreadcrumbSeparator>
<BreadcrumbItem>
<ProjectGraphQLPagesComboBox />
</BreadcrumbItem>
</>
)}
</>
)}
</BreadcrumbList>

View File

@@ -0,0 +1,135 @@
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>
);
}

View File

@@ -160,12 +160,24 @@ 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] = {
@@ -243,13 +255,22 @@ 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,
children:
_page.name === 'Settings' && !shouldDisableSettings
? projectSettingsPages.map(
(p) => `${org.slug}-${_app.subdomain}-settings-${p.slug}`,
)
: undefined,
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;
})(),
data: {
name: _page.name,
icon: _page.icon,
@@ -285,6 +306,20 @@ 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`] = {
@@ -436,6 +471,12 @@ export default function NavTree() {
return;
}
if (item.data.name === 'GraphQL' && item.isFolder) {
if (!context.isExpanded) {
context.toggleExpandedState();
}
}
if (item.data.type !== 'org') {
context.focusItem();
}

View File

@@ -0,0 +1,20 @@
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>
);
}

View File

@@ -0,0 +1,30 @@
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 };

View File

@@ -12,6 +12,7 @@ import { ApplicationUnpausing } from '@/features/orgs/projects/common/components
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
import { isEmptyValue } from '@/lib/utils';
import { useAuth } from '@/providers/Auth';
import { ApplicationStatus } from '@/types/application';
import { getConfigServerUrl, isPlatform as isPlatformFn } from '@/utils/env';
@@ -188,6 +189,15 @@ function ProjectLayoutContent({
throw error;
}
if (
isUserLoggedIn &&
isEmptyValue(project) &&
!loading &&
isEmptyValue(error)
) {
throw new Error('Could not load project. Please try again later.');
}
return (
<Box
component="main"

View File

@@ -65,37 +65,39 @@ export default function CreateUserForm({
onDirtyStateChange(isDirty, location);
}, [isDirty, location, onDirtyStateChange]);
const baseAuthUrl =
isNotEmptyValue(project?.subdomain) && isNotEmptyValue(project?.region)
? generateAppServiceUrl(project!.subdomain, project!.region, 'auth')
: '';
const signUpUrl = `${baseAuthUrl}/signup/email-password`;
async function handleCreateUser({ email, password }: CreateUserFormValues) {
setCreateUserFormError(null);
await execPromiseWithErrorToast(
async () => {
await fetch(signUpUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
}).then(async (res) => {
if (isNotEmptyValue(project)) {
const baseAuthUrl = generateAppServiceUrl(
project.subdomain,
project.region,
'auth',
);
const signUpUrl = `${baseAuthUrl}/signup/email-password`;
const res = await fetch(signUpUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (res.ok) {
return data;
if (!res.ok) {
if (res.status === 409) {
setError('email', { message: data?.message });
}
throw new Error(data?.message || 'Something went wrong.');
}
if (res.status === 409) {
setError('email', { message: data?.message });
}
onSubmit?.();
throw new Error(data?.message || 'Something went wrong.');
});
onSubmit?.();
return data;
}
throw new Error('Something went wrong. Please try again later.');
},
{
loadingMessage: 'Creating user...',

View File

@@ -0,0 +1 @@
export { default as useGetMetadataResourceVersion } from './useGetMetadataResourceVersion';

View File

@@ -0,0 +1,46 @@
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;
}

View File

@@ -0,0 +1,31 @@
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;
}
}

View File

@@ -0,0 +1 @@
export { default as fetchExportMetadata } from './fetchExportMetadata';

View File

@@ -1,359 +0,0 @@
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: &quot;{value}&quot;</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>
);
}

View File

@@ -1,149 +0,0 @@
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>
);
}

View File

@@ -1,2 +0,0 @@
export * from './BaseColumnForm';
export { default as BaseColumnForm } from './BaseColumnForm';

View File

@@ -4,7 +4,6 @@ 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,
@@ -51,6 +50,23 @@ 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.')

View File

@@ -1,11 +1,9 @@
import { useDialog } from '@/components/common/DialogProvider';
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
import { InlineCode } from '@/components/presentational/InlineCode';
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
import { InlineCode } from '@/components/ui/v3/inline-code';
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';
@@ -21,8 +19,6 @@ 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';
@@ -31,18 +27,11 @@ 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(
@@ -63,7 +52,7 @@ export function createDataGridColumn(
const defaultColumnConfiguration = {
Header: () => (
<div className="grid grid-flow-col items-center justify-start gap-1 font-normal">
{column.is_primary && <KeyIcon className="text-sm" />}
{column.is_primary && <KeyRound width={14} height={14} />}
<span className="truncate font-bold" title={column.column_name}>
{column.column_name}
@@ -167,23 +156,15 @@ export default function DataBrowserGrid({
...router
} = useRouter();
const currentTablePath = useTablePath();
const isSchemaEditable = !isSchemaLocked(schemaSlug as string);
const { openDrawer, openAlertDialog } = useDialog();
const { project } = useProject();
const isGitHubConnected = !!project?.githubRepository;
const { openDrawer } = useDialog();
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],
@@ -271,26 +252,16 @@ 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;
},
isDisabled: removableColumnId === column.column_name,
}))
.filter((column) => column.id !== optimisticlyRemovedColumnId),
[
columns,
currentTablePath,
optimisticlyRemovedColumnId,
queryClient,
removableColumnId,
updateRow,
],
return result;
},
})),
[columns, currentTablePath, queryClient, updateRow],
);
const memoizedData = useMemo(() => rows, [rows]);
@@ -308,58 +279,6 @@ 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
@@ -414,8 +333,6 @@ 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,
@@ -423,12 +340,6 @@ export default function DataBrowserGrid({
autoResetSelectedRows: false,
autoResetResize: false,
}}
headerProps={{
componentsProps: {
editActionProps: { disabled: isGitHubConnected },
deleteActionProps: { disabled: isGitHubConnected },
},
}}
controls={
<DataBrowserGridControls
onInsertRowClick={handleInsertRowClick}

View File

@@ -603,7 +603,7 @@ export default function DataBrowserSidebar({
</Box>
<IconButton
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
className="absolute bottom-4 left-8 z-[38] h-11 w-11 rounded-full md:hidden"
onClick={toggleExpanded}
aria-label="Toggle sidebar"
>

View File

@@ -1,151 +0,0 @@
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>
);
}

View File

@@ -1,2 +0,0 @@
export * from './EditColumnForm';
export { default as EditColumnForm } from './EditColumnForm';

View File

@@ -1,58 +0,0 @@
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);
}

View File

@@ -1,82 +0,0 @@
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);
}

View File

@@ -1,4 +0,0 @@
export * from './deleteColumn';
export * from './useDeleteColumnMutation';
export { default as useDeleteColumnMutation } from './useDeleteColumnMutation';
export { default as useDeleteColumnWithToastMutation } from './useDeleteColumnWithToastMutation';

View File

@@ -1,65 +0,0 @@
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;
}

View File

@@ -1,53 +0,0 @@
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 };
}

View File

@@ -1,79 +0,0 @@
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: [],
});
});

View File

@@ -1,53 +0,0 @@
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 || [],
};
}

View File

@@ -1 +0,0 @@
export { default as convertDataBrowserGridColumnToDatabaseColumn } from './convertDataBrowserGridColumnToDatabaseColumn';

View File

@@ -26,6 +26,10 @@ 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
@@ -67,6 +71,15 @@ 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,
@@ -75,6 +88,8 @@ const useNavTreeStateFromURL = (): TreeState => {
projectPage,
settingsPage,
isSettingsPage,
isGraphQLPage,
graphqlSubPage,
newProject,
]);
};

View File

@@ -0,0 +1,210 @@
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>
);
}

View File

@@ -0,0 +1,200 @@
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>
);
}

View File

@@ -0,0 +1,26 @@
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>
);
}

View File

@@ -0,0 +1,269 @@
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>
);
}

View File

@@ -0,0 +1,43 @@
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>
);
}

View File

@@ -0,0 +1,37 @@
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>
);
}

View File

@@ -0,0 +1,40 @@
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>
);
}

View File

@@ -0,0 +1,43 @@
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>
);
}

View File

@@ -0,0 +1,2 @@
export * from './BaseRemoteSchemaForm';
export { default as BaseTableForm } from './BaseRemoteSchemaForm';

View File

@@ -0,0 +1,91 @@
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>
);
}

View File

@@ -0,0 +1,3 @@
export { default as BaseRemoteSchemaRelationshipForm } from './BaseRemoteSchemaRelationshipForm';
export type { DatabaseRelationshipFormValues } from './sections/DatabaseRelationshipForm';
export type { RemoteSchemaRelationshipFormValues } from './sections/RemoteSchemaRelationshipForm';

View File

@@ -0,0 +1,238 @@
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>
);
}

View File

@@ -0,0 +1,121 @@
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>
);
}

View File

@@ -0,0 +1,206 @@
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>
)}
/>
</>
);
}

View File

@@ -0,0 +1,69 @@
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>
);
}

View File

@@ -0,0 +1,234 @@
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>
);
}

View File

@@ -0,0 +1,259 @@
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>
);
}

View File

@@ -0,0 +1,127 @@
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>
)}
/>
);
}

View File

@@ -0,0 +1,114 @@
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>
)}
/>
);
}

View File

@@ -0,0 +1,116 @@
import { Button } from '@/components/ui/v3/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/v3/command';
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/v3/form';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/v3/popover';
import { cn } from '@/lib/utils';
import type { RemoteSchemaInfo } from '@/utils/hasura-api/generated/schemas';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import type { RemoteSchemaRelationshipFormValues } from './RemoteSchemaRelationshipForm';
export interface TargetRemoteSchemaComboboxProps {
disabled?: boolean;
remoteSchemas: RemoteSchemaInfo[];
}
export default function TargetRemoteSchemaCombobox({
disabled,
remoteSchemas,
}: TargetRemoteSchemaComboboxProps) {
const [open, setOpen] = useState(false);
const form = useFormContext<RemoteSchemaRelationshipFormValues>();
return (
<FormField
control={form.control}
name="targetRemoteSchema"
render={({ field }) => (
<FormItem className="flex flex-1 flex-col">
<FormLabel>Target Remote Schema</FormLabel>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
disabled={disabled}
variant="outline"
role="combobox"
className={cn(
'w-full justify-between',
!field.value && 'text-muted-foreground',
{
'border-destructive':
form.formState.errors.targetRemoteSchema,
},
)}
>
{field.value
? remoteSchemas.find(
(schema) => schema.name === field.value,
)?.name
: 'Select remote schema'}
<ChevronsUpDown className="opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput
placeholder="Search remote schema..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No remote schema found.</CommandEmpty>
<CommandGroup>
{remoteSchemas.map((schema) => (
<CommandItem
value={schema.name}
key={schema.name}
onSelect={() => {
form.setValue('targetRemoteSchema', schema.name, {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
setOpen(false);
}}
>
{schema.name}
<Check
className={cn(
'ml-auto',
schema.name === field.value
? 'opacity-100'
: 'opacity-0',
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItem>
)}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More