Compare commits
46 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4854df4559 | ||
|
|
865dd93fbe | ||
|
|
0c50816717 | ||
|
|
d3b4fc358e | ||
|
|
b3bcacb300 | ||
|
|
aa7ecdb38f | ||
|
|
20672c7a9b | ||
|
|
29d27e19b4 | ||
|
|
46fc520707 | ||
|
|
21e90da476 | ||
|
|
b944d053d0 | ||
|
|
6902a36512 | ||
|
|
aea6d186c2 | ||
|
|
a535aa3834 | ||
|
|
c9dca09478 | ||
|
|
414896491f | ||
|
|
cdf6776523 | ||
|
|
1b40e99530 | ||
|
|
8b5c4a0951 | ||
|
|
f5594ef991 | ||
|
|
eb9556280c | ||
|
|
c87736eeeb | ||
|
|
714dffa5ec | ||
|
|
760835d80f | ||
|
|
6a34f891a5 | ||
|
|
037bd74764 | ||
|
|
0f6ce52c4e | ||
|
|
6696172bcb | ||
|
|
b0e848d353 | ||
|
|
cea3ef5c8a | ||
|
|
a05db74bb6 | ||
|
|
73f3d69776 | ||
|
|
a99f034bd4 | ||
|
|
3b37af06a0 | ||
|
|
86ecf27b23 | ||
|
|
1b5dc5e7f5 | ||
|
|
21708be3d2 | ||
|
|
f16e2305c3 | ||
|
|
5d6c349350 | ||
|
|
245a1b44c4 | ||
|
|
ca75f731af | ||
|
|
c48be24d13 | ||
|
|
60b5bf20d7 | ||
|
|
8f94bc6332 | ||
|
|
75c73c4884 | ||
|
|
4c6a6bb6c1 |
@@ -14,22 +14,22 @@ runs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 8.10.5
|
version: 9.15.0
|
||||||
run_install: false
|
run_install: false
|
||||||
- name: Get pnpm cache directory
|
- name: Get pnpm cache directory
|
||||||
id: pnpm-cache-dir
|
id: pnpm-cache-dir
|
||||||
shell: bash
|
shell: bash
|
||||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v4
|
||||||
id: pnpm-cache
|
id: pnpm-cache
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
restore-keys: ${{ runner.os }}-node-
|
restore-keys: ${{ runner.os }}-node-
|
||||||
- name: Use Node.js v18
|
- name: Use Node.js v20
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
- shell: bash
|
- shell: bash
|
||||||
name: Install packages
|
name: Install packages
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
26
.github/workflows/changesets.yaml
vendored
26
.github/workflows/changesets.yaml
vendored
@@ -65,29 +65,13 @@ jobs:
|
|||||||
|
|
||||||
publish-vercel:
|
publish-vercel:
|
||||||
name: Publish to Vercel
|
name: Publish to Vercel
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
needs:
|
||||||
- test
|
- test
|
||||||
steps:
|
uses: ./.github/workflows/deploy-dashboard.yaml
|
||||||
- name: Checkout repository
|
with:
|
||||||
uses: actions/checkout@v3
|
git_ref: ${{ github.ref_name }}
|
||||||
with:
|
environment: production
|
||||||
fetch-depth: 0
|
secrets: inherit
|
||||||
- name: Install Node and dependencies
|
|
||||||
uses: ./.github/actions/install-dependencies
|
|
||||||
with:
|
|
||||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
|
||||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
|
||||||
- name: Setup Vercel CLI
|
|
||||||
run: pnpm add -g vercel
|
|
||||||
- name: Trigger a Vercel deployment
|
|
||||||
env:
|
|
||||||
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.DASHBOARD_VERCEL_PROJECT_ID }}
|
|
||||||
run: |
|
|
||||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
|
||||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
|
||||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
|
||||||
|
|
||||||
publish-docker:
|
publish-docker:
|
||||||
name: Publish to Docker Hub
|
name: Publish to Docker Hub
|
||||||
|
|||||||
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -18,7 +18,6 @@ env:
|
|||||||
TURBO_TEAM: nhost
|
TURBO_TEAM: nhost
|
||||||
NEXT_PUBLIC_ENV: dev
|
NEXT_PUBLIC_ENV: dev
|
||||||
NEXT_TELEMETRY_DISABLED: 1
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
|
||||||
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
||||||
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
|
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
|
||||||
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
|
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
|
||||||
|
|||||||
1
.github/workflows/dashboard.yaml
vendored
1
.github/workflows/dashboard.yaml
vendored
@@ -8,7 +8,6 @@ env:
|
|||||||
TURBO_TEAM: nhost
|
TURBO_TEAM: nhost
|
||||||
NEXT_PUBLIC_ENV: dev
|
NEXT_PUBLIC_ENV: dev
|
||||||
NEXT_TELEMETRY_DISABLED: 1
|
NEXT_TELEMETRY_DISABLED: 1
|
||||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
58
.github/workflows/deploy-dashboard.yaml
vendored
Normal file
58
.github/workflows/deploy-dashboard.yaml
vendored
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
name: 'dashboard: release form'
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
git_ref:
|
||||||
|
type: string
|
||||||
|
description: 'Branch, tag, or commit SHA'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
environment:
|
||||||
|
type: choice
|
||||||
|
description: 'Deployment environment'
|
||||||
|
required: true
|
||||||
|
default: staging
|
||||||
|
options:
|
||||||
|
- staging
|
||||||
|
- production
|
||||||
|
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
git_ref:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
environment:
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish-vercel:
|
||||||
|
name: Publish to Vercel
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.git_ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Install Node and dependencies
|
||||||
|
uses: ./.github/actions/install-dependencies
|
||||||
|
with:
|
||||||
|
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||||
|
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||||
|
|
||||||
|
- name: Setup Vercel CLI
|
||||||
|
run: pnpm add -g vercel
|
||||||
|
|
||||||
|
- name: Trigger Vercel deployment
|
||||||
|
env:
|
||||||
|
VERCEL_ORG_ID: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
|
||||||
|
VERCEL_PROJECT_ID: ${{ inputs.environment == 'production' && secrets.DASHBOARD_VERCEL_PROJECT_ID || secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
|
||||||
|
run: |
|
||||||
|
echo "Deploying to: ${{ inputs.environment }}..."
|
||||||
|
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
|
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
|
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||||
3
.github/workflows/gen_ai_review.yaml
vendored
3
.github/workflows/gen_ai_review.yaml
vendored
@@ -12,12 +12,11 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
contents: write
|
|
||||||
name: Run pr agent on every pull request, respond to user comments
|
name: Run pr agent on every pull request, respond to user comments
|
||||||
steps:
|
steps:
|
||||||
- name: PR Agent action step
|
- name: PR Agent action step
|
||||||
id: pragent
|
id: pragent
|
||||||
uses: Codium-ai/pr-agent@v0.24
|
uses: Codium-ai/pr-agent@v0.26
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ module.exports = {
|
|||||||
env: (config) => ({
|
env: (config) => ({
|
||||||
...config,
|
...config,
|
||||||
NEXT_PUBLIC_ENV: 'dev',
|
NEXT_PUBLIC_ENV: 'dev',
|
||||||
NEXT_PUBLIC_NHOST_BACKEND_URL: 'http://localhost:1337',
|
|
||||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,68 @@
|
|||||||
# @nhost/dashboard
|
# @nhost/dashboard
|
||||||
|
|
||||||
|
## 2.13.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 21e90da: chore: remove restrictions on SMTP sender so My Name <name@acme.com> can be added
|
||||||
|
- 865dd93: fix: duplicate Run placeholders when there is an error in the backend
|
||||||
|
- 6902a36: fix: can remove resources if postgres capacity is higher than 10
|
||||||
|
- a535aa3: fix: fetch user roles locally in auth section
|
||||||
|
- 0c50816: fix: allow decimal numbers in database row insert
|
||||||
|
- aea6d18: chore: add warning when pausing a project about losing Run services persistent volume data
|
||||||
|
- d3b4fc3: feat: allow to change postgres settings if project is paused
|
||||||
|
- 29d27e1: chore: update `next` to v14.2.22 to fix vulnerabilities
|
||||||
|
- c9dca09: feat: add reset password form
|
||||||
|
- b3bcacb: fix: paused project banner cannot read null project name
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [46fc520]
|
||||||
|
- Updated dependencies [29d27e1]
|
||||||
|
- @nhost/nextjs@2.2.0
|
||||||
|
- @nhost/react-apollo@15.0.1
|
||||||
|
|
||||||
|
## 2.12.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- eb95562: fix: show all available permission variables in permission dropdown select
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8b5c4a0: chore: cleanup layout and add disable duplicate atom key checking in development mode
|
||||||
|
|
||||||
|
## 2.11.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 714dffa: fix: improve project polling logic and unify usage across components
|
||||||
|
|
||||||
|
## 2.11.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 6a34f89: fix: improve project polling logic and unify usage across components
|
||||||
|
|
||||||
|
## 2.11.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 0f6ce52: fix: consolidate useProject hook and fix jwt expired error
|
||||||
|
|
||||||
|
## 2.11.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- cea3ef5: Feat: add org and project placeholders
|
||||||
|
|
||||||
|
## 2.10.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 86ecf27: feat: add support for additional metrics in overview
|
||||||
|
- 21708be: feat: dashboard: add support for storage buckets to AI assistants
|
||||||
|
|
||||||
## 1.30.0
|
## 1.30.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
FROM node:18-alpine AS pruner
|
FROM node:20-alpine AS pruner
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN yarn global add turbo@1.11.3
|
RUN yarn global add turbo@2.2.3
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||||
|
|
||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
ARG TURBO_TOKEN
|
ARG TURBO_TOKEN
|
||||||
ARG TURBO_TEAM
|
ARG TURBO_TEAM
|
||||||
|
|
||||||
@@ -15,22 +15,22 @@ RUN apk add --no-cache libc6-compat python3 make g++
|
|||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_PUBLIC_ENV dev
|
ENV NEXT_PUBLIC_ENV=dev
|
||||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
ENV NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||||
|
|
||||||
# placeholders for URLs, will be replaced on runtime by entrypoint script
|
# placeholders for URLs, will be replaced on runtime by entrypoint script
|
||||||
ENV NEXT_PUBLIC_NHOST_ADMIN_SECRET __NEXT_PUBLIC_NHOST_ADMIN_SECRET__
|
ENV NEXT_PUBLIC_NHOST_ADMIN_SECRET=__NEXT_PUBLIC_NHOST_ADMIN_SECRET__
|
||||||
ENV NEXT_PUBLIC_NHOST_AUTH_URL __NEXT_PUBLIC_NHOST_AUTH_URL__
|
ENV NEXT_PUBLIC_NHOST_AUTH_URL=__NEXT_PUBLIC_NHOST_AUTH_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_FUNCTIONS_URL __NEXT_PUBLIC_NHOST_FUNCTIONS_URL__
|
ENV NEXT_PUBLIC_NHOST_FUNCTIONS_URL=__NEXT_PUBLIC_NHOST_FUNCTIONS_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_GRAPHQL_URL __NEXT_PUBLIC_NHOST_GRAPHQL_URL__
|
ENV NEXT_PUBLIC_NHOST_GRAPHQL_URL=__NEXT_PUBLIC_NHOST_GRAPHQL_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_STORAGE_URL __NEXT_PUBLIC_NHOST_STORAGE_URL__
|
ENV NEXT_PUBLIC_NHOST_STORAGE_URL=__NEXT_PUBLIC_NHOST_STORAGE_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL __NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__
|
ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=__NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL __NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
|
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL __NEXT_PUBLIC_NHOST_HASURA_API_URL__
|
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL=__NEXT_PUBLIC_NHOST_HASURA_API_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL __NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
||||||
|
|
||||||
RUN yarn global add pnpm@8.10.5
|
RUN yarn global add pnpm@9.15.0
|
||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
COPY --from=pruner /app/out/pnpm-*.yaml .
|
||||||
@@ -41,7 +41,7 @@ COPY turbo.json turbo.json
|
|||||||
COPY config/ config/
|
COPY config/ config/
|
||||||
RUN pnpm build:dashboard
|
RUN pnpm build:dashboard
|
||||||
|
|
||||||
FROM node:18-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
@@ -58,4 +58,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/standalone/app ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
||||||
|
|
||||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
CMD ["node", "dashboard/server.js"]
|
CMD ["node", "dashboard/server.js"]
|
||||||
@@ -100,7 +100,6 @@ pnpm storybook --port 6007
|
|||||||
|
|
||||||
| Name | Description |
|
| Name | Description |
|
||||||
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
|
| --------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||||
| `NEXT_PUBLIC_NHOST_BACKEND_URL` | Backend URL. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
|
||||||
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
| `NEXT_PUBLIC_STRIPE_PK` | Stripe public key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||||
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
| `NEXT_PUBLIC_GITHUB_APP_INSTALL_URL` | URL of the GitHub application. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||||
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
| `NEXT_PUBLIC_ANALYTICS_WRITE_KEY` | Analytics key. This is only used if `NEXT_PUBLIC_NHOST_PLATFORM` is `true`. |
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "2.7.2",
|
"version": "2.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"dev": "next dev",
|
"dev": "RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false next dev",
|
||||||
"build": "next build --no-lint",
|
"build": "next build --no-lint",
|
||||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"just-kebab-case": "^4.2.0",
|
"just-kebab-case": "^4.2.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lucide-react": "^0.416.0",
|
"lucide-react": "^0.416.0",
|
||||||
"next": "^14.2.10",
|
"next": "^14.2.22",
|
||||||
"next-nprogress-bar": "^2.3.13",
|
"next-nprogress-bar": "^2.3.13",
|
||||||
"next-seo": "^6.5.0",
|
"next-seo": "^6.5.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
|||||||
137
dashboard/src/components/common/SelectOrg/SelectOrg.tsx
Normal file
137
dashboard/src/components/common/SelectOrg/SelectOrg.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
|
||||||
|
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||||
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
|
import { List } from '@/components/ui/v2/List';
|
||||||
|
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||||
|
import { } from '@/utils/__generated__/graphql';
|
||||||
|
import { Divider } from '@mui/material';
|
||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export default function SelectOrganizationAndProject() {
|
||||||
|
const { orgs, loading } = useOrgs();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const organizations = orgs.map((org) => ({
|
||||||
|
name: org.name,
|
||||||
|
value: `/orgs/${org.slug}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilter(event.target.value);
|
||||||
|
}, 200),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||||
|
|
||||||
|
const goToOrgPage = async (org: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
}) => {
|
||||||
|
const { slug } = router.query;
|
||||||
|
await router.push({
|
||||||
|
pathname: `${org.value}/${
|
||||||
|
(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||||
|
})()
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const orgsToDisplay = filter
|
||||||
|
? organizations.filter((org) =>
|
||||||
|
org.name.toLowerCase().includes(filter.toLowerCase()),
|
||||||
|
)
|
||||||
|
: organizations;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={500}
|
||||||
|
label="Loading organizations..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||||
|
<Text variant="h2" component="h1" className="">
|
||||||
|
Select an Organization
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RetryableErrorBoundary>
|
||||||
|
{orgsToDisplay.length === 0 ? (
|
||||||
|
<Box className="h-import py-2">
|
||||||
|
<Text variant="subtitle2">No results found.</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List className="h-import overflow-y-auto">
|
||||||
|
{orgsToDisplay.map((org, index) => (
|
||||||
|
<Fragment key={org.value}>
|
||||||
|
<ListItem.Root
|
||||||
|
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||||
|
secondaryAction={
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => goToOrgPage(org)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem.Avatar>
|
||||||
|
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||||
|
<Image
|
||||||
|
src="/logos/new.svg"
|
||||||
|
alt="Nhost Logo"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</ListItem.Avatar>
|
||||||
|
<ListItem.Text
|
||||||
|
primary={org.name}
|
||||||
|
secondary={`${org.name} / ${org.name}`}
|
||||||
|
/>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
{index < orgs.length - 1 && <Divider component="li" />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</RetryableErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
dashboard/src/components/common/SelectOrg/index.ts
Normal file
1
dashboard/src/components/common/SelectOrg/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as SelectOrg } from './SelectOrg';
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
|
||||||
|
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||||
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
|
import { List } from '@/components/ui/v2/List';
|
||||||
|
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||||
|
import { } from '@/utils/__generated__/graphql';
|
||||||
|
import { Divider } from '@mui/material';
|
||||||
|
import debounce from 'lodash.debounce';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import type { ChangeEvent } from 'react';
|
||||||
|
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
export default function SelectOrganizationAndProject() {
|
||||||
|
const { orgs, loading } = useOrgs();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const projects = orgs.flatMap((org) =>
|
||||||
|
org.apps.map((app) => ({
|
||||||
|
organizationName: org.name,
|
||||||
|
projectName: app.name,
|
||||||
|
value: `/orgs/${org.slug}/projects/${app.subdomain}`,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [filter, setFilter] = useState('');
|
||||||
|
|
||||||
|
const handleFilterChange = useMemo(
|
||||||
|
() =>
|
||||||
|
debounce((event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setFilter(event.target.value);
|
||||||
|
}, 200),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => handleFilterChange.cancel(), [handleFilterChange]);
|
||||||
|
|
||||||
|
const goToProjectPage = async (project: {
|
||||||
|
organizationName: string;
|
||||||
|
projectName: string;
|
||||||
|
value: string;
|
||||||
|
}) => {
|
||||||
|
const { slug } = router.query;
|
||||||
|
await router.push({
|
||||||
|
pathname: `${project.value}/${
|
||||||
|
(() => {
|
||||||
|
if (!slug) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return Array.isArray(slug) ? slug.join('/') : slug;
|
||||||
|
})()
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectsToDisplay = filter
|
||||||
|
? projects.filter((project) =>
|
||||||
|
project.projectName.toLowerCase().includes(filter.toLowerCase()),
|
||||||
|
)
|
||||||
|
: projects;
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full justify-center">
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={500}
|
||||||
|
label="Loading organizations and projects..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||||
|
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||||
|
<Text variant="h2" component="h1" className="">
|
||||||
|
Select a Project
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 flex w-full">
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
onChange={handleFilterChange}
|
||||||
|
fullWidth
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RetryableErrorBoundary>
|
||||||
|
{projectsToDisplay.length === 0 ? (
|
||||||
|
<Box className="h-import py-2">
|
||||||
|
<Text variant="subtitle2">No results found.</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<List className="h-import overflow-y-auto">
|
||||||
|
{projectsToDisplay.map((project, index) => (
|
||||||
|
<Fragment key={project.value}>
|
||||||
|
<ListItem.Root
|
||||||
|
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||||
|
secondaryAction={
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="primary"
|
||||||
|
onClick={() => goToProjectPage(project)}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ListItem.Avatar>
|
||||||
|
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||||
|
<Image
|
||||||
|
src="/logos/new.svg"
|
||||||
|
alt="Nhost Logo"
|
||||||
|
width={24}
|
||||||
|
height={24}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</ListItem.Avatar>
|
||||||
|
<ListItem.Text
|
||||||
|
primary={project.projectName}
|
||||||
|
secondary={`${project.organizationName} / ${project.projectName}`}
|
||||||
|
/>
|
||||||
|
</ListItem.Root>
|
||||||
|
|
||||||
|
{index < projects.length - 1 && <Divider component="li" />}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</RetryableErrorBoundary>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
|
||||||
|
export { default as SelectOrgAndProject } from './SelectOrgAndProject';
|
||||||
@@ -20,8 +20,7 @@ interface AINavLinkProps extends ListItemButtonProps {
|
|||||||
*/
|
*/
|
||||||
href: string;
|
href: string;
|
||||||
/**
|
/**
|
||||||
* Determines whether or not the link should be active if it's href exactly
|
* Determines whether or not the link should be active if href matches the current route.
|
||||||
* matches the current route.
|
|
||||||
*
|
*
|
||||||
* @default true
|
* @default true
|
||||||
*/
|
*/
|
||||||
@@ -87,7 +86,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
|||||||
<>
|
<>
|
||||||
<Backdrop
|
<Backdrop
|
||||||
open={expanded}
|
open={expanded}
|
||||||
className="absolute top-0 left-0 bottom-0 right-0 z-[34] md:hidden"
|
className="absolute bottom-0 left-0 right-0 top-0 z-[34] md:hidden"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={() => setExpanded(false)}
|
onClick={() => setExpanded(false)}
|
||||||
@@ -104,7 +103,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
|||||||
<Box
|
<Box
|
||||||
component="aside"
|
component="aside"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pt-2 pb-17 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -119,6 +118,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
|||||||
>
|
>
|
||||||
Auto-Embeddings
|
Auto-Embeddings
|
||||||
</AINavLink>
|
</AINavLink>
|
||||||
|
|
||||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||||
Assistants
|
Assistants
|
||||||
</AINavLink>
|
</AINavLink>
|
||||||
|
|||||||
@@ -21,22 +21,9 @@ import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoun
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import { useEffect, useState } from 'react';
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type DetailedHTMLProps,
|
|
||||||
type HTMLProps,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export interface AuthenticatedLayoutProps extends BaseLayoutProps {
|
export interface AuthenticatedLayoutProps extends BaseLayoutProps {}
|
||||||
/**
|
|
||||||
* Props passed to the internal content container.
|
|
||||||
*/
|
|
||||||
contentContainerProps?: DetailedHTMLProps<
|
|
||||||
HTMLProps<HTMLDivElement>,
|
|
||||||
HTMLDivElement
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthenticatedLayout({
|
export default function AuthenticatedLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -8,20 +8,15 @@ import {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
} from '@/components/ui/v3/command';
|
} from '@/components/ui/v3/command';
|
||||||
import {
|
|
||||||
HoverCard,
|
|
||||||
HoverCardContent,
|
|
||||||
HoverCardTrigger,
|
|
||||||
} from '@/components/ui/v3/hover-card';
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/v3/popover';
|
} from '@/components/ui/v3/popover';
|
||||||
|
import { ProjectStatusIndicator } from '@/features/orgs/components/common/ProjectStatusIndicator';
|
||||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { ApplicationStatus } from '@/types/application';
|
|
||||||
import { Box, Check, ChevronsUpDown } from 'lucide-react';
|
import { Box, Check, ChevronsUpDown } from 'lucide-react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
@@ -31,56 +26,6 @@ type Option = {
|
|||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function ProjectStatusIndicator({ status }: { status: ApplicationStatus }) {
|
|
||||||
const indicatorStyles: Record<
|
|
||||||
number,
|
|
||||||
{ className: string; description: string }
|
|
||||||
> = {
|
|
||||||
[ApplicationStatus.Errored]: {
|
|
||||||
className: 'bg-destructive',
|
|
||||||
description: 'Project errored',
|
|
||||||
},
|
|
||||||
[ApplicationStatus.Pausing]: {
|
|
||||||
className: 'bg-primary-main animate-blinking',
|
|
||||||
description: 'Project is pausing',
|
|
||||||
},
|
|
||||||
[ApplicationStatus.Restoring]: {
|
|
||||||
className: 'bg-primary-main animate-blinking',
|
|
||||||
description: 'Project is restoring',
|
|
||||||
},
|
|
||||||
[ApplicationStatus.Paused]: {
|
|
||||||
className: 'bg-slate-400',
|
|
||||||
description: 'Project is paused',
|
|
||||||
},
|
|
||||||
[ApplicationStatus.Unpausing]: {
|
|
||||||
className: 'bg-primary-main animate-blinking',
|
|
||||||
description: 'Project is unpausing',
|
|
||||||
},
|
|
||||||
[ApplicationStatus.Live]: {
|
|
||||||
className: 'bg-primary-main',
|
|
||||||
description: 'Project is live',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const style = indicatorStyles[status];
|
|
||||||
|
|
||||||
if (style) {
|
|
||||||
return (
|
|
||||||
<HoverCard openDelay={0}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<span
|
|
||||||
className={cn('mt-[1px] h-2 w-2 rounded-full', style.className)}
|
|
||||||
/>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent side="top" className="h-fit w-fit py-2">
|
|
||||||
{style.description}
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProjectsComboBox() {
|
export default function ProjectsComboBox() {
|
||||||
const {
|
const {
|
||||||
query: { appSubdomain },
|
query: { appSubdomain },
|
||||||
|
|||||||
@@ -21,23 +21,22 @@ export default function UnauthenticatedLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||||
|
const isOnResetPassword = router.route === '/password/reset';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlatform || (!isLoading && isAuthenticated)) {
|
if (!isPlatform || (!isLoading && isAuthenticated)) {
|
||||||
router.push('/');
|
// we do not want to redirect if the user tries to reset their password
|
||||||
|
if (!isOnResetPassword) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
}, [isLoading, isAuthenticated, router, isPlatform, isOnResetPassword]);
|
||||||
|
|
||||||
if (!isPlatform || isLoading || isAuthenticated) {
|
if ((!isPlatform || isLoading || isAuthenticated) && !isOnResetPassword) {
|
||||||
return (
|
return (
|
||||||
<BaseLayout {...props}>
|
<BaseLayout {...props}>
|
||||||
<LoadingScreen
|
<LoadingScreen
|
||||||
sx={{ backgroundColor: (theme) => theme.palette.background.default }}
|
sx={{ backgroundColor: (theme) => theme.palette.background.default }}
|
||||||
slotProps={{
|
|
||||||
activityIndicator: {
|
|
||||||
className: 'text-white',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
);
|
);
|
||||||
@@ -59,19 +58,19 @@ export default function UnauthenticatedLayout({
|
|||||||
|
|
||||||
<RetryableErrorBoundary>
|
<RetryableErrorBoundary>
|
||||||
<Box
|
<Box
|
||||||
className="flex items-center min-h-screen"
|
className="flex min-h-screen items-center"
|
||||||
sx={{ backgroundColor: (theme) => theme.palette.common.black }}
|
sx={{ backgroundColor: (theme) => theme.palette.common.black }}
|
||||||
>
|
>
|
||||||
<Container
|
<Container
|
||||||
rootClassName="bg-transparent h-full"
|
rootClassName="bg-transparent h-full"
|
||||||
className="grid items-center w-full h-full gap-12 pt-8 pb-12 bg-transparent justify-items-center lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
||||||
>
|
>
|
||||||
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
|
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
|
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
|
||||||
<div className="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center w-full h-full max-w-xl mx-auto overflow-hidden opacity-70">
|
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
|
||||||
<Image
|
<Image
|
||||||
priority
|
priority
|
||||||
src="/assets/line-grid.svg"
|
src="/assets/line-grid.svg"
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { IconProps } from '@/components/ui/v2/icons';
|
||||||
|
import { SvgIcon } from '@/components/ui/v2/icons/SvgIcon';
|
||||||
|
|
||||||
|
function FileStoresIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
aria-label="FileStores Icon"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path d="M12 22v-9" />
|
||||||
|
<path d="M15.17 2.21a1.67 1.67 0 0 1 1.63 0L21 4.57a1.93 1.93 0 0 1 0 3.36L8.82 14.79a1.655 1.655 0 0 1-1.64 0L3 12.43a1.93 1.93 0 0 1 0-3.36z" />
|
||||||
|
<path d="M20 13v3.87a2.06 2.06 0 0 1-1.11 1.83l-6 3.08a1.93 1.93 0 0 1-1.78 0l-6-3.08A2.06 2.06 0 0 1 4 16.87V13" />
|
||||||
|
<path d="M21 12.43a1.93 1.93 0 0 0 0-3.36L8.83 2.2a1.64 1.64 0 0 0-1.63 0L3 4.57a1.93 1.93 0 0 0 0 3.36l12.18 6.86a1.636 1.636 0 0 0 1.63 0z" />
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileStoresIcon.displayName = 'FileStoresIcon';
|
||||||
|
|
||||||
|
export default FileStoresIcon;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FileStoresIcon } from './FileStoresIcon';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type DialogProps } from '@radix-ui/react-dialog';
|
import { type DialogProps } from '@radix-ui/react-dialog';
|
||||||
import { Command as CommandPrimitive } from 'cmdk';
|
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||||
import { Search } from 'lucide-react';
|
import { PlusIcon, Search } from 'lucide-react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { Dialog, DialogContent } from '@/components/ui/v3/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/v3/dialog';
|
||||||
@@ -26,7 +26,7 @@ interface CommandDialogProps extends DialogProps {}
|
|||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="p-0 overflow-hidden shadow-lg">
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
@@ -37,14 +37,22 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
|
||||||
>(({ className, ...props }, ref) => (
|
prefix?: React.ReactNode;
|
||||||
<div className="flex items-center px-3 border-b" cmdk-input-wrapper="">
|
}
|
||||||
<Search className="w-4 h-4 mr-2 opacity-50 shrink-0" />
|
>(({ className, prefix, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
{prefix && (
|
||||||
|
<span className="pointer-events-none flex items-center text-muted-foreground">
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-11 w-full rounded-md border-none bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-11 w-full rounded-md border-none bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
prefix && 'pl-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,7 +81,7 @@ const CommandEmpty = React.forwardRef<
|
|||||||
>((props, ref) => (
|
>((props, ref) => (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="py-6 text-sm text-center"
|
className="py-6 text-center text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -140,6 +148,25 @@ const CommandShortcut = ({
|
|||||||
};
|
};
|
||||||
CommandShortcut.displayName = 'CommandShortcut';
|
CommandShortcut.displayName = 'CommandShortcut';
|
||||||
|
|
||||||
|
const CommandCreateItem = ({
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
onCreate: (value: string) => void;
|
||||||
|
}) => {
|
||||||
|
const query = useCommandState((state) => state.search);
|
||||||
|
if (!query || !onCreate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem forceMount value="create" onSelect={() => onCreate(query)}>
|
||||||
|
<PlusIcon className="mr-2" /> {query}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CommandCreateItem.displayName = 'CommandCreateItem';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -150,4 +177,5 @@ export {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
|
CommandCreateItem,
|
||||||
};
|
};
|
||||||
|
|||||||
183
dashboard/src/components/ui/v3/fancy-multi-select.tsx
Normal file
183
dashboard/src/components/ui/v3/fancy-multi-select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/v3/badge';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/v3/command';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type Option = Record<'value' | 'label', string>;
|
||||||
|
|
||||||
|
interface FancyMultiSelectProps {
|
||||||
|
defaultValue?: Option[];
|
||||||
|
options?: Option[];
|
||||||
|
creatable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onChange?: (selected: Option[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FancyMultiSelect({
|
||||||
|
defaultValue = [],
|
||||||
|
options = [],
|
||||||
|
creatable = false,
|
||||||
|
className,
|
||||||
|
onChange,
|
||||||
|
}: FancyMultiSelectProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Option[]>(defaultValue);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const handleUnselect = useCallback((option: Option) => {
|
||||||
|
setSelected((prev) => prev.filter((s) => s.value !== option.value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (input) {
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (input.value === '') {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const newSelected = [...prev];
|
||||||
|
newSelected.pop();
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This is not a default behaviour of the <input /> field
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(option: Option) => {
|
||||||
|
setInputValue('');
|
||||||
|
setSelected((prev) => {
|
||||||
|
const newSelected = [...prev, option];
|
||||||
|
onChange?.(newSelected);
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectables = useMemo(() => {
|
||||||
|
const filtered = options.filter(
|
||||||
|
(option) =>
|
||||||
|
!selected.map((s) => s.value).includes(option.value) &&
|
||||||
|
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (creatable && inputValue) {
|
||||||
|
return [
|
||||||
|
...filtered,
|
||||||
|
{
|
||||||
|
value: inputValue.toLowerCase(),
|
||||||
|
label: inputValue,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [options, selected, inputValue, creatable]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="relative overflow-visible bg-transparent"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex min-h-10 flex-1 rounded-md border bg-background px-4 py-0 text-sm ring-offset-background hover:bg-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-1 overflow-x-hidden py-1">
|
||||||
|
{selected.map((option) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="h-7 overflow-x-hidden text-[12px] font-normal"
|
||||||
|
key={option.value}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<span className="overflow-x-hidden text-ellipsis whitespace-nowrap break-words font-medium">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove ${option.label}`}
|
||||||
|
className="ml-1 rounded-full outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleUnselect(option);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={() => handleUnselect(option)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Avoid having the "Search" Icon */}
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder="Select options..."
|
||||||
|
className="flex flex-1 border-none bg-transparent px-0 py-1 text-sm font-medium outline-none !ring-0 !ring-offset-0 placeholder:text-sm placeholder:text-muted-foreground group-hover:text-accent-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList>
|
||||||
|
{open && selectables.length > 0 ? (
|
||||||
|
<div className="absolute top-2 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||||
|
<CommandGroup className="h-full overflow-auto">
|
||||||
|
{selectables.map((option) => {
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onSelect={() => handleSelect(option)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{creatable &&
|
||||||
|
!options.find((opt) => opt.value === option.value)
|
||||||
|
? `Create "${option.label}"`
|
||||||
|
: option.label}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
|
||||||
import { Form } from '@/components/form/Form';
|
import { Form } from '@/components/form/Form';
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
@@ -10,14 +11,14 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||||
import { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
import { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
||||||
import { WebhooksDataSourcesFormSection } from '@/features/ai/AssistantForm/components/WebhooksDataSourcesFormSection';
|
import { WebhooksDataSourcesFormSection } from '@/features/ai/AssistantForm/components/WebhooksDataSourcesFormSection';
|
||||||
import { useAdminApolloClient } from '@/features/projects/common/hooks/useAdminApolloClient';
|
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient'
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
|
||||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
|
||||||
import {
|
import {
|
||||||
useInsertAssistantMutation,
|
useInsertAssistantMutation,
|
||||||
useUpdateAssistantMutation,
|
useUpdateAssistantMutation,
|
||||||
} from '@/utils/__generated__/graphite.graphql';
|
} from '@/utils/__generated__/graphite.graphql';
|
||||||
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
|
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
@@ -28,6 +29,7 @@ export const validationSchema = Yup.object({
|
|||||||
description: Yup.string(),
|
description: Yup.string(),
|
||||||
instructions: Yup.string().required('The instructions are required'),
|
instructions: Yup.string().required('The instructions are required'),
|
||||||
model: Yup.string().required('The model is required'),
|
model: Yup.string().required('The model is required'),
|
||||||
|
fileStore: Yup.string().label('File Store'),
|
||||||
graphql: Yup.array().of(
|
graphql: Yup.array().of(
|
||||||
Yup.object().shape({
|
Yup.object().shape({
|
||||||
name: Yup.string().required(),
|
name: Yup.string().required(),
|
||||||
@@ -64,14 +66,14 @@ export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
|||||||
|
|
||||||
export interface AssistantFormProps extends DialogFormProps {
|
export interface AssistantFormProps extends DialogFormProps {
|
||||||
/**
|
/**
|
||||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
* To use in conjunction with initialData to allow for updating the Assistant Configuration
|
||||||
*/
|
*/
|
||||||
assistantId?: string;
|
assistantId?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if there is initialData then it's an update operation
|
* if there is initialData then it's an update operation
|
||||||
*/
|
*/
|
||||||
initialData?: AssistantFormValues;
|
initialData?: AssistantFormValues
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to be called when the operation is cancelled.
|
* Function to be called when the operation is cancelled.
|
||||||
@@ -114,26 +116,26 @@ export default function AssistantForm({
|
|||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onDirtyStateChange(isDirty, location);
|
onDirtyStateChange(isDirty, location);
|
||||||
}, [isDirty, location, onDirtyStateChange]);
|
}, [isDirty, location, onDirtyStateChange]);
|
||||||
|
|
||||||
const createOrUpdateAutoEmbeddings = async (
|
const createOrUpdateAssistant = async (
|
||||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
values: DeepRequired<AssistantFormValues> & {
|
||||||
|
assistantID: string;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
// remove any __typename from the form values
|
// remove any __typename from the form values
|
||||||
const payload = removeTypename(values);
|
const payload = removeTypename(values);
|
||||||
|
|
||||||
if (values.webhooks.length === 0) {
|
if (values.webhooks?.length === 0) {
|
||||||
delete payload.webhooks;
|
delete payload.webhooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.graphql.length === 0) {
|
if (values.graphql?.length === 0) {
|
||||||
delete payload.graphql;
|
delete payload.graphql;
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove assistantId because the update mutation fails otherwise
|
|
||||||
delete payload.assistantID;
|
delete payload.assistantID;
|
||||||
|
|
||||||
// If the assistantId is set then we do an update
|
// If the assistantId is set then we do an update
|
||||||
@@ -158,11 +160,13 @@ export default function AssistantForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (
|
const handleSubmit = async (
|
||||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
values: DeepRequired<AssistantFormValues> & {
|
||||||
|
assistantID: string;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
await execPromiseWithErrorToast(
|
await execPromiseWithErrorToast(
|
||||||
async () => {
|
async () => {
|
||||||
await createOrUpdateAutoEmbeddings(values);
|
await createOrUpdateAssistant(values);
|
||||||
onSubmit?.();
|
onSubmit?.();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -282,6 +286,7 @@ export default function AssistantForm({
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GraphqlDataSourcesFormSection />
|
<GraphqlDataSourcesFormSection />
|
||||||
<WebhooksDataSourcesFormSection />
|
<WebhooksDataSourcesFormSection />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,12 +15,12 @@ import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
|||||||
|
|
||||||
interface AssistantsListProps {
|
interface AssistantsListProps {
|
||||||
/**
|
/**
|
||||||
* The run services fetched from entering the users page.
|
* The list of assistants.
|
||||||
*/
|
*/
|
||||||
assistants: Assistant[];
|
assistants: Assistant[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to be called after a submitting the form for either creating or updating a service.
|
* Function to be called after a submitting the form for either creating or updating an assistant.
|
||||||
*
|
*
|
||||||
* @example onDelete={() => refetch()}
|
* @example onDelete={() => refetch()}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ export default function Estimate() {
|
|||||||
|
|
||||||
const amountDue = useMemo(() => {
|
const amountDue = useMemo(() => {
|
||||||
const amount = data?.billingGetNextInvoice?.AmountDue;
|
const amount = data?.billingGetNextInvoice?.AmountDue;
|
||||||
if (!amount) {
|
|
||||||
return 'N/A';
|
|
||||||
}
|
|
||||||
if (typeof amount !== 'number') {
|
if (typeof amount !== 'number') {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
HoverCard,
|
||||||
|
HoverCardContent,
|
||||||
|
HoverCardTrigger,
|
||||||
|
} from '@/components/ui/v3/hover-card';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ApplicationStatus } from '@/types/application';
|
||||||
|
|
||||||
|
export default function ProjectStatusIndicator({
|
||||||
|
status,
|
||||||
|
}: {
|
||||||
|
status: ApplicationStatus;
|
||||||
|
}) {
|
||||||
|
const indicatorStyles: Record<
|
||||||
|
number,
|
||||||
|
{ className: string; description: string }
|
||||||
|
> = {
|
||||||
|
[ApplicationStatus.Errored]: {
|
||||||
|
className: 'bg-destructive',
|
||||||
|
description: 'Project errored',
|
||||||
|
},
|
||||||
|
[ApplicationStatus.Pausing]: {
|
||||||
|
className: 'bg-primary-main animate-blinking',
|
||||||
|
description: 'Project is pausing',
|
||||||
|
},
|
||||||
|
[ApplicationStatus.Restoring]: {
|
||||||
|
className: 'bg-primary-main animate-blinking',
|
||||||
|
description: 'Project is restoring',
|
||||||
|
},
|
||||||
|
[ApplicationStatus.Paused]: {
|
||||||
|
className: 'bg-slate-400',
|
||||||
|
description: 'Project is paused',
|
||||||
|
},
|
||||||
|
[ApplicationStatus.Unpausing]: {
|
||||||
|
className: 'bg-primary-main animate-blinking',
|
||||||
|
description: 'Project is unpausing',
|
||||||
|
},
|
||||||
|
[ApplicationStatus.Live]: {
|
||||||
|
className: 'bg-primary-main',
|
||||||
|
description: 'Project is live',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const style = indicatorStyles[status];
|
||||||
|
|
||||||
|
if (style) {
|
||||||
|
return (
|
||||||
|
<HoverCard openDelay={0}>
|
||||||
|
<HoverCardTrigger asChild>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'mt-[2px] h-2 w-2 flex-shrink-0 rounded-full',
|
||||||
|
style.className,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</HoverCardTrigger>
|
||||||
|
<HoverCardContent side="top" className="h-fit w-fit py-2">
|
||||||
|
{style.description}
|
||||||
|
</HoverCardContent>
|
||||||
|
</HoverCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ProjectStatusIndicator } from './ProjectStatusIndicator';
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
import { Button } from '@/components/ui/v3/button';
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import { ProjectStatusIndicator } from '@/features/orgs/components/common/ProjectStatusIndicator';
|
||||||
|
import { DeploymentStatusMessage } from '@/features/orgs/projects/deployments/components/DeploymentStatusMessage';
|
||||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
import { DeploymentStatusMessage } from '@/features/projects/deployments/components/DeploymentStatusMessage';
|
|
||||||
import {
|
import {
|
||||||
useGetProjectsQuery,
|
useGetProjectsQuery,
|
||||||
type GetProjectsQuery,
|
type GetProjectsQuery,
|
||||||
@@ -22,20 +23,21 @@ function ProjectCard({ project }: { project: Project }) {
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/orgs/${org?.slug}/projects/${project.subdomain}`}
|
href={`/orgs/${org?.slug}/projects/${project.subdomain}`}
|
||||||
className="flex cursor-pointer flex-col gap-4 rounded-lg border bg-background p-4 hover:shadow-sm"
|
className="flex h-44 cursor-pointer flex-col gap-4 rounded-lg border bg-background p-4 hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex flex-row items-start gap-2">
|
||||||
<div className="flex w-full flex-row items-center space-x-2">
|
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
|
||||||
<Box className="h-6 w-6 flex-shrink-0" />
|
<div className="flex w-full flex-col">
|
||||||
<p className="truncate text-lg font-bold">{project.name}</p>
|
<p className="truncate font-bold">{project.name}</p>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{project.region.name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ProjectStatusIndicator status={project.appStates[0].stateId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-row items-start gap-2">
|
<div className="flex flex-1 flex-row items-start gap-2">
|
||||||
<DeploymentStatusMessage
|
<DeploymentStatusMessage deployment={latestDeployment} />
|
||||||
appCreatedAt={project.createdAt}
|
|
||||||
deployment={latestDeployment}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex w-full justify-end">
|
<div className="flex w-full justify-end">
|
||||||
@@ -53,6 +55,7 @@ export default function ProjectsGrid() {
|
|||||||
orgSlug: org?.slug,
|
orgSlug: org?.slug,
|
||||||
},
|
},
|
||||||
skip: !org,
|
skip: !org,
|
||||||
|
pollInterval: 10 * 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
@@ -100,7 +103,7 @@ export default function ProjectsGrid() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
|
<div className="grid grid-cols-1 gap-4 p-4 sm:grid-cols-2 md:grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||||
{filteredProjects.map((project) => (
|
{filteredProjects.map((project) => (
|
||||||
<ProjectCard key={project.id} project={project} />
|
<ProjectCard key={project.id} project={project} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useMemo } from 'react';
|
|||||||
* @returns A function that returns a new ApolloClient instance.
|
* @returns A function that returns a new ApolloClient instance.
|
||||||
*/
|
*/
|
||||||
export default function useRemoteApplicationGQLClient() {
|
export default function useRemoteApplicationGQLClient() {
|
||||||
const { project, loading } = useProject({ target: 'user-project' });
|
const { project, loading } = useProject();
|
||||||
const serviceUrl = generateAppServiceUrl(
|
const serviceUrl = generateAppServiceUrl(
|
||||||
project?.subdomain,
|
project?.subdomain,
|
||||||
project?.region,
|
project?.region,
|
||||||
|
|||||||
@@ -128,6 +128,9 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
|||||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||||
Assistants
|
Assistants
|
||||||
</AINavLink>
|
</AINavLink>
|
||||||
|
<AINavLink href="/file-stores" exact={false} onClick={handleSelect}>
|
||||||
|
File Stores
|
||||||
|
</AINavLink>
|
||||||
</List>
|
</List>
|
||||||
</nav>
|
</nav>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { ApplicationUnknown } from '@/features/orgs/projects/common/components/A
|
|||||||
import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
|
import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
|
||||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
||||||
import { ApplicationStatus } from '@/types/application';
|
import { ApplicationStatus } from '@/types/application';
|
||||||
import { NextSeo } from 'next-seo';
|
import { NextSeo } from 'next-seo';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -37,7 +37,7 @@ function ProjectLayoutContent({
|
|||||||
|
|
||||||
const { state } = useAppState();
|
const { state } = useAppState();
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { project, loading, error } = useProject({ poll: true });
|
const { project, loading, error } = useProjectWithState();
|
||||||
|
|
||||||
const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
|
const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
|
||||||
|
|
||||||
|
|||||||
@@ -14,21 +14,27 @@ import { WebhooksDataSourcesFormSection } from '@/features/orgs/projects/ai/Assi
|
|||||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
|
||||||
import {
|
import {
|
||||||
useInsertAssistantMutation,
|
useInsertAssistantMutation,
|
||||||
useUpdateAssistantMutation,
|
useUpdateAssistantMutation,
|
||||||
} from '@/utils/__generated__/graphite.graphql';
|
} from '@/utils/__generated__/graphite.graphql';
|
||||||
|
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||||
|
import { Option } from '@/components/ui/v2/Option';
|
||||||
|
import { useIsFileStoreSupported } from '@/features/orgs/projects/common/hooks/useIsFileStoreSupported';
|
||||||
|
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||||
|
|
||||||
export const validationSchema = Yup.object({
|
export const validationSchema = Yup.object({
|
||||||
name: Yup.string().required('The name is required.'),
|
name: Yup.string().required('The name is required.'),
|
||||||
description: Yup.string(),
|
description: Yup.string(),
|
||||||
instructions: Yup.string().required('The instructions are required'),
|
instructions: Yup.string().required('The instructions are required'),
|
||||||
model: Yup.string().required('The model is required'),
|
model: Yup.string().required('The model is required'),
|
||||||
|
fileStore: Yup.string().label('File Store'),
|
||||||
graphql: Yup.array().of(
|
graphql: Yup.array().of(
|
||||||
Yup.object().shape({
|
Yup.object().shape({
|
||||||
name: Yup.string().required(),
|
name: Yup.string().required(),
|
||||||
@@ -65,14 +71,17 @@ export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
|||||||
|
|
||||||
export interface AssistantFormProps extends DialogFormProps {
|
export interface AssistantFormProps extends DialogFormProps {
|
||||||
/**
|
/**
|
||||||
* To use in conjunction with initialData to allow for updating the autoEmbeddingsConfiguration
|
* To use in conjunction with initialData to allow for updating the Assistant Configuration
|
||||||
*/
|
*/
|
||||||
assistantId?: string;
|
assistantId?: string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* if there is initialData then it's an update operation
|
* if there is initialData then it's an update operation
|
||||||
*/
|
*/
|
||||||
initialData?: AssistantFormValues;
|
initialData?: AssistantFormValues & {
|
||||||
|
fileStores?: string[];
|
||||||
|
};
|
||||||
|
fileStores?: GraphiteFileStore[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to be called when the operation is cancelled.
|
* Function to be called when the operation is cancelled.
|
||||||
@@ -87,6 +96,7 @@ export interface AssistantFormProps extends DialogFormProps {
|
|||||||
export default function AssistantForm({
|
export default function AssistantForm({
|
||||||
assistantId,
|
assistantId,
|
||||||
initialData,
|
initialData,
|
||||||
|
fileStores,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
location,
|
location,
|
||||||
@@ -103,8 +113,27 @@ export default function AssistantForm({
|
|||||||
client: adminClient,
|
client: adminClient,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isFileStoreSupported = useIsFileStoreSupported();
|
||||||
|
|
||||||
|
const fileStoresOptions = fileStores
|
||||||
|
? fileStores.map((fileStore: GraphiteFileStore) => ({
|
||||||
|
label: fileStore.name,
|
||||||
|
value: fileStore.name,
|
||||||
|
id: fileStore.id,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const assistantFileStore = initialData?.fileStores
|
||||||
|
? fileStores?.find((fileStore: GraphiteFileStore) =>
|
||||||
|
fileStore.id === initialData?.fileStores[0]
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const formDefaultValues = { ...initialData, fileStores: [] };
|
||||||
|
formDefaultValues.fileStore = assistantFileStore ? assistantFileStore.id : '';
|
||||||
|
|
||||||
const form = useForm<AssistantFormValues>({
|
const form = useForm<AssistantFormValues>({
|
||||||
defaultValues: initialData,
|
defaultValues: formDefaultValues,
|
||||||
reValidateMode: 'onSubmit',
|
reValidateMode: 'onSubmit',
|
||||||
resolver: yupResolver(validationSchema),
|
resolver: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
@@ -120,22 +149,32 @@ export default function AssistantForm({
|
|||||||
onDirtyStateChange(isDirty, location);
|
onDirtyStateChange(isDirty, location);
|
||||||
}, [isDirty, location, onDirtyStateChange]);
|
}, [isDirty, location, onDirtyStateChange]);
|
||||||
|
|
||||||
const createOrUpdateAutoEmbeddings = async (
|
const createOrUpdateAssistant = async (
|
||||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
values: DeepRequired<AssistantFormValues> & {
|
||||||
|
assistantID: string;
|
||||||
|
},
|
||||||
) => {
|
) => {
|
||||||
// remove any __typename from the form values
|
// remove any __typename from the form values
|
||||||
const payload = removeTypename(values);
|
const payload = removeTypename(values);
|
||||||
|
|
||||||
if (values.webhooks.length === 0) {
|
if (values.webhooks?.length === 0) {
|
||||||
delete payload.webhooks;
|
delete payload.webhooks;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (values.graphql.length === 0) {
|
if (values.graphql?.length === 0) {
|
||||||
delete payload.graphql;
|
delete payload.graphql;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isFileStoreSupported && values.fileStore) {
|
||||||
|
payload.fileStores = [values.fileStore];
|
||||||
|
}
|
||||||
|
if (!isFileStoreSupported) {
|
||||||
|
delete payload.fileStores;
|
||||||
|
}
|
||||||
|
|
||||||
// remove assistantId because the update mutation fails otherwise
|
// remove assistantId because the update mutation fails otherwise
|
||||||
delete payload.assistantID;
|
delete payload.assistantID;
|
||||||
|
delete payload.fileStore;
|
||||||
|
|
||||||
// If the assistantId is set then we do an update
|
// If the assistantId is set then we do an update
|
||||||
if (assistantId) {
|
if (assistantId) {
|
||||||
@@ -152,7 +191,7 @@ export default function AssistantForm({
|
|||||||
await insertAssistantMutation({
|
await insertAssistantMutation({
|
||||||
variables: {
|
variables: {
|
||||||
data: {
|
data: {
|
||||||
...values,
|
...payload,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -163,7 +202,7 @@ export default function AssistantForm({
|
|||||||
) => {
|
) => {
|
||||||
await execPromiseWithErrorToast(
|
await execPromiseWithErrorToast(
|
||||||
async () => {
|
async () => {
|
||||||
await createOrUpdateAutoEmbeddings(values);
|
await createOrUpdateAssistant(values);
|
||||||
onSubmit?.();
|
onSubmit?.();
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -175,6 +214,10 @@ export default function AssistantForm({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fileStoreTooltip = isFileStoreSupported
|
||||||
|
? "If specified, all text documents in this file store will be available to the assistant."
|
||||||
|
: "Please upgrade Graphite to its latest version in order to use file stores.";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<Form
|
<Form
|
||||||
@@ -285,6 +328,36 @@ export default function AssistantForm({
|
|||||||
/>
|
/>
|
||||||
<GraphqlDataSourcesFormSection />
|
<GraphqlDataSourcesFormSection />
|
||||||
<WebhooksDataSourcesFormSection />
|
<WebhooksDataSourcesFormSection />
|
||||||
|
<ControlledSelect
|
||||||
|
slotProps={{
|
||||||
|
popper: { disablePortal: false, className: 'z-[10000]' },
|
||||||
|
}}
|
||||||
|
id="fileStore"
|
||||||
|
name="fileStore"
|
||||||
|
label={
|
||||||
|
<Box className="flex flex-row items-center space-x-2">
|
||||||
|
<Text>File Store</Text>
|
||||||
|
<Tooltip title={fileStoreTooltip}>
|
||||||
|
<InfoIcon
|
||||||
|
aria-label="Info"
|
||||||
|
className="h-4 w-4"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
error={!!errors?.model?.message}
|
||||||
|
helperText={errors?.model?.message}
|
||||||
|
disabled={!isFileStoreSupported}
|
||||||
|
>
|
||||||
|
<Option value="" />
|
||||||
|
{fileStoresOptions.map((fileStore) => (
|
||||||
|
<Option key={fileStore.id} value={fileStore.id}>
|
||||||
|
{fileStore.label}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</ControlledSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Box className="flex flex-row justify-between w-full p-4 border-t rounded">
|
<Box className="flex flex-row justify-between w-full p-4 border-t rounded">
|
||||||
|
|||||||
@@ -11,16 +11,22 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { AssistantForm } from '@/features/orgs/projects/ai/AssistantForm';
|
import { AssistantForm } from '@/features/orgs/projects/ai/AssistantForm';
|
||||||
import { DeleteAssistantModal } from '@/features/orgs/projects/ai/DeleteAssistantModal';
|
import { DeleteAssistantModal } from '@/features/orgs/projects/ai/DeleteAssistantModal';
|
||||||
import { type Assistant } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/assistants';
|
import { type Assistant } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/assistants';
|
||||||
|
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||||
import { copy } from '@/utils/copy';
|
import { copy } from '@/utils/copy';
|
||||||
|
|
||||||
interface AssistantsListProps {
|
interface AssistantsListProps {
|
||||||
/**
|
/**
|
||||||
* The run services fetched from entering the users page.
|
* The list of assistants
|
||||||
*/
|
*/
|
||||||
assistants: Assistant[];
|
assistants: Assistant[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function to be called after a submitting the form for either creating or updating a service.
|
* The list of file stores
|
||||||
|
*/
|
||||||
|
fileStores: GraphiteFileStore[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called after a submitting the form for either creating or updating an assistant.
|
||||||
*
|
*
|
||||||
* @example onDelete={() => refetch()}
|
* @example onDelete={() => refetch()}
|
||||||
*/
|
*/
|
||||||
@@ -35,6 +41,7 @@ interface AssistantsListProps {
|
|||||||
|
|
||||||
export default function AssistantsList({
|
export default function AssistantsList({
|
||||||
assistants,
|
assistants,
|
||||||
|
fileStores,
|
||||||
onCreateOrUpdate,
|
onCreateOrUpdate,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: AssistantsListProps) {
|
}: AssistantsListProps) {
|
||||||
@@ -49,6 +56,7 @@ export default function AssistantsList({
|
|||||||
initialData={{
|
initialData={{
|
||||||
...assistant,
|
...assistant,
|
||||||
}}
|
}}
|
||||||
|
fileStores={fileStores}
|
||||||
onSubmit={() => onCreateOrUpdate()}
|
onSubmit={() => onCreateOrUpdate()}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||||
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
|
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||||
|
import { useDeleteFileStoreMutation } from '@/utils/__generated__/graphite.graphql';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export interface DeleteFileStoreModalProps {
|
||||||
|
fileStore: GraphiteFileStore;
|
||||||
|
onDelete?: () => Promise<any>;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteFileStoreModal({
|
||||||
|
fileStore,
|
||||||
|
onDelete,
|
||||||
|
close,
|
||||||
|
}: DeleteFileStoreModalProps) {
|
||||||
|
const [remove, setRemove] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const { adminClient } = useAdminApolloClient();
|
||||||
|
|
||||||
|
const [deleteFileStoreMutation] = useDeleteFileStoreMutation({
|
||||||
|
client: adminClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteFileStore = async () => {
|
||||||
|
await deleteFileStoreMutation({
|
||||||
|
variables: {
|
||||||
|
id: fileStore.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await onDelete?.();
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleClick() {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
await execPromiseWithErrorToast(deleteFileStore, {
|
||||||
|
loadingMessage: 'Deleting the file store...',
|
||||||
|
successMessage: 'The file store has been deleted successfully.',
|
||||||
|
errorMessage:
|
||||||
|
'An error occurred while deleting the file store. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={twMerge('w-full rounded-lg p-6 text-left')}>
|
||||||
|
{' '}
|
||||||
|
<div className="grid grid-flow-row gap-1">
|
||||||
|
{' '}
|
||||||
|
<Text variant="h3" component="h2">
|
||||||
|
{' '}
|
||||||
|
Delete File Store {fileStore?.name}{' '}
|
||||||
|
</Text>{' '}
|
||||||
|
<Text variant="subtitle2">
|
||||||
|
{' '}
|
||||||
|
Are you sure you want to delete this File Store?{' '}
|
||||||
|
</Text>{' '}
|
||||||
|
<Text
|
||||||
|
variant="subtitle2"
|
||||||
|
className="font-bold"
|
||||||
|
sx={{ color: (theme) => `${theme.palette.error.main} !important` }}
|
||||||
|
>
|
||||||
|
This cannot be undone.
|
||||||
|
</Text>
|
||||||
|
<Box className="my-4">
|
||||||
|
<Checkbox
|
||||||
|
id="accept-1"
|
||||||
|
label={`I'm sure I want to delete ${fileStore?.name}`}
|
||||||
|
className="py-2"
|
||||||
|
checked={remove}
|
||||||
|
onChange={(_event, checked) => setRemove(checked)}
|
||||||
|
aria-label="Confirm Delete File Store"
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<Button
|
||||||
|
color="error"
|
||||||
|
onClick={handleClick}
|
||||||
|
disabled={!remove}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
|
Delete File Store
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outlined" color="secondary" onClick={close}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as DeleteFileStoreModal } from './DeleteFileStoreModal';
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||||
|
import { Form } from '@/components/form/Form';
|
||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { ArrowsClockwise } from '@/components/ui/v2/icons/ArrowsClockwise';
|
||||||
|
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||||
|
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||||
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||||
|
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient'
|
||||||
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
|
import type { DialogFormProps } from '@/types/common';
|
||||||
|
import {
|
||||||
|
useInsertFileStoreMutation,
|
||||||
|
useUpdateFileStoreMutation,
|
||||||
|
} from '@/utils/__generated__/graphite.graphql';
|
||||||
|
import { useGetBucketsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
|
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
|
export const validationSchema = Yup.object({
|
||||||
|
name: Yup.string().required('The name is required'),
|
||||||
|
buckets: Yup.array()
|
||||||
|
.of(
|
||||||
|
Yup.object({
|
||||||
|
label: Yup.string(),
|
||||||
|
value: Yup.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.label('Buckets')
|
||||||
|
.required('At least one bucket is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type FileStoreFormValues = Yup.InferType<typeof validationSchema>;
|
||||||
|
|
||||||
|
export interface FileStoreFormProps extends DialogFormProps {
|
||||||
|
id?: string;
|
||||||
|
initialData?: Omit<FileStoreFormValues, 'buckets'> & { buckets: string[] };
|
||||||
|
onSubmit?: VoidFunction | ((args?: any) => Promise<any>);
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileStoreForm({
|
||||||
|
id,
|
||||||
|
initialData,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
location,
|
||||||
|
}: FileStoreFormProps) {
|
||||||
|
const { onDirtyStateChange } = useDialog();
|
||||||
|
|
||||||
|
const { adminClient } = useAdminApolloClient();
|
||||||
|
|
||||||
|
const [insertFileStore] = useInsertFileStoreMutation({
|
||||||
|
client: adminClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [updateFileStore] = useUpdateFileStoreMutation({
|
||||||
|
client: adminClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
|
const { data: buckets } = useGetBucketsQuery({
|
||||||
|
client: remoteProjectGQLClient,
|
||||||
|
});
|
||||||
|
|
||||||
|
const bucketOptions = buckets
|
||||||
|
? buckets.buckets.map((bucket) => ({
|
||||||
|
label: bucket.id,
|
||||||
|
value: bucket.id,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const formDefaultValues = { ...initialData, buckets: [] };
|
||||||
|
formDefaultValues.buckets = initialData?.buckets
|
||||||
|
? initialData.buckets.map((bucket) => ({
|
||||||
|
label: bucket,
|
||||||
|
value: bucket,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const form = useForm<FileStoreFormValues>({
|
||||||
|
defaultValues: formDefaultValues,
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
resolver: yupResolver(validationSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
formState: { errors, isSubmitting, dirtyFields },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyStateChange(isDirty, location);
|
||||||
|
}, [isDirty, location, onDirtyStateChange]);
|
||||||
|
|
||||||
|
const createOrUpdateFileStore = async (
|
||||||
|
values: DeepRequired<FileStoreFormValues> & { id: string },
|
||||||
|
) => {
|
||||||
|
const payload = removeTypename(values);
|
||||||
|
delete payload.id;
|
||||||
|
delete payload.vectorStoreID;
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
await updateFileStore({
|
||||||
|
variables: {
|
||||||
|
id,
|
||||||
|
object: { ...payload, buckets: values.buckets.map((b) => b.value) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await insertFileStore({
|
||||||
|
variables: {
|
||||||
|
object: { ...values, buckets: values.buckets.map((b) => b.value) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (
|
||||||
|
values: DeepRequired<FileStoreFormValues> & { id: string },
|
||||||
|
) => {
|
||||||
|
await execPromiseWithErrorToast(
|
||||||
|
async () => {
|
||||||
|
await createOrUpdateFileStore(values);
|
||||||
|
onSubmit?.();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingMessage: 'Creating File Store...',
|
||||||
|
successMessage: 'The File Store has been created successfully.',
|
||||||
|
errorMessage:
|
||||||
|
'An error occurred while creating the File Store. Please try again.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
<Form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex h-full flex-col overflow-hidden border-t"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col space-y-4 overflow-auto p-4">
|
||||||
|
<Input
|
||||||
|
{...register('name')}
|
||||||
|
id="name"
|
||||||
|
label={
|
||||||
|
<Box className="flex flex-row items-center space-x-2">
|
||||||
|
<Text>Name</Text>
|
||||||
|
<Tooltip title="Name of the file store">
|
||||||
|
<InfoIcon
|
||||||
|
aria-label="Info"
|
||||||
|
className="h-4 w-4"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
placeholder=""
|
||||||
|
hideEmptyHelperText
|
||||||
|
error={!!errors.name}
|
||||||
|
helperText={errors?.name?.message}
|
||||||
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ControlledAutocomplete
|
||||||
|
id="buckets"
|
||||||
|
name="buckets"
|
||||||
|
label={
|
||||||
|
<Box className="flex flex-row items-center space-x-2">
|
||||||
|
<Text>Buckets</Text>
|
||||||
|
<Tooltip title="One or more buckets from storage from which documents can be used by Assistants">
|
||||||
|
<InfoIcon
|
||||||
|
aria-label="Info"
|
||||||
|
className="h-4 w-4"
|
||||||
|
color="primary"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
fullWidth
|
||||||
|
multiple
|
||||||
|
aria-label="Buckets"
|
||||||
|
error={!!errors.buckets}
|
||||||
|
options={bucketOptions}
|
||||||
|
helperText={errors?.buckets?.message}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Box className="flex w-full flex-row justify-between rounded border-t p-4">
|
||||||
|
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
startIcon={id ? <ArrowsClockwise /> : <PlusIcon />}
|
||||||
|
>
|
||||||
|
{id ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FileStoreForm } from './FileStoreForm';
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
|
import { Divider } from '@/components/ui/v2/Divider';
|
||||||
|
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||||
|
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||||
|
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||||
|
import { DotsHorizontalIcon } from '@/components/ui/v2/icons/DotsHorizontalIcon';
|
||||||
|
import { FileStoresIcon } from '@/components/ui/v2/icons/FileStoresIcon';
|
||||||
|
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||||
|
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { DeleteFileStoreModal } from '@/features/orgs/projects/ai/DeleteFileStoreModal';
|
||||||
|
import { FileStoreForm } from '@/features/orgs/projects/ai/FileStoreForm';
|
||||||
|
import { type GraphiteFileStore } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/ai/file-stores';
|
||||||
|
import { copy } from '@/utils/copy';
|
||||||
|
|
||||||
|
interface FileStoresListProps {
|
||||||
|
/**
|
||||||
|
* List of File Stores to be displayed.
|
||||||
|
*/
|
||||||
|
fileStores: GraphiteFileStore[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called after a submitting the form for either creating or updating a File Store.
|
||||||
|
*
|
||||||
|
* @example onDelete={() => refetch()}
|
||||||
|
*/
|
||||||
|
onCreateOrUpdate?: () => Promise<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function to be called after a successful delete action.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
onDelete?: () => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileStoresList({
|
||||||
|
fileStores,
|
||||||
|
onCreateOrUpdate,
|
||||||
|
onDelete,
|
||||||
|
}: FileStoresListProps) {
|
||||||
|
const { openDrawer, openDialog, closeDialog } = useDialog();
|
||||||
|
|
||||||
|
const viewFileStore = async (fileStore: GraphiteFileStore) => {
|
||||||
|
openDrawer({
|
||||||
|
title: fileStore.name,
|
||||||
|
component: (
|
||||||
|
<FileStoreForm
|
||||||
|
id={fileStore.id}
|
||||||
|
initialData={{ ...fileStore }}
|
||||||
|
onSubmit={() => onCreateOrUpdate()}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteFileStore = async (fileStore: GraphiteFileStore) => {
|
||||||
|
openDialog({
|
||||||
|
component: (
|
||||||
|
<DeleteFileStoreModal
|
||||||
|
fileStore={fileStore}
|
||||||
|
close={closeDialog}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col">
|
||||||
|
{fileStores.map((fileStore) => (
|
||||||
|
<Box
|
||||||
|
key={fileStore.id}
|
||||||
|
className="flex h-[64px] w-full cursor-pointer items-center justify-between space-x-4 border-b-1 px-4 py-2 transition-colors"
|
||||||
|
sx={{
|
||||||
|
[`&:hover`]: {
|
||||||
|
backgroundColor: 'action.hover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
onClick={() => viewFileStore(fileStore)}
|
||||||
|
className="flex w-full flex-row justify-between"
|
||||||
|
sx={{ backgroundColor: 'transparent' }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||||
|
<FileStoresIcon className="h-5 w-5" />
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<Text variant="h4" className="font-semibold">
|
||||||
|
{fileStore?.name ?? 'unset'}
|
||||||
|
</Text>
|
||||||
|
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||||
|
<Text variant="subtitle1" className="font-mono text-xs">
|
||||||
|
{fileStore.id}
|
||||||
|
</Text>
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
onClick={(event) => {
|
||||||
|
copy(fileStore.id, 'File Store Id');
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
aria-label="Service Id"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-4 w-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Dropdown.Root>
|
||||||
|
<Dropdown.Trigger
|
||||||
|
asChild
|
||||||
|
hideChevron
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
aria-label="More options"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DotsHorizontalIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Dropdown.Trigger>
|
||||||
|
<Dropdown.Content
|
||||||
|
menu
|
||||||
|
PaperProps={{ className: 'w-auto' }}
|
||||||
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||||
|
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||||
|
>
|
||||||
|
<Dropdown.Item
|
||||||
|
onClick={() => viewFileStore(fileStore)}
|
||||||
|
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||||
|
>
|
||||||
|
<UserIcon className="h-4 w-4" />
|
||||||
|
<Text className="font-medium">View {fileStore?.name}</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Divider component="li" />
|
||||||
|
<Dropdown.Item
|
||||||
|
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||||
|
sx={{ color: 'error.main' }}
|
||||||
|
onClick={() => deleteFileStore(fileStore)}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
<Text className="font-medium" color="error">
|
||||||
|
Delete {fileStore?.name}
|
||||||
|
</Text>
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Content>
|
||||||
|
</Dropdown.Root>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FileStoresList } from './FileStoresList';
|
||||||
@@ -19,7 +19,7 @@ import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWith
|
|||||||
|
|
||||||
const validationSchema = yup
|
const validationSchema = yup
|
||||||
.object({
|
.object({
|
||||||
sender: yup.string().label('SMTP Sender').email().required(),
|
sender: yup.string().label('SMTP Sender').required(),
|
||||||
password: yup.string().label('Password').required(),
|
password: yup.string().label('Password').required(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const smtpValidationSchema = yup
|
|||||||
user: yup.string().label('Username').required(),
|
user: yup.string().label('Username').required(),
|
||||||
password: yup.string().label('Password'),
|
password: yup.string().label('Password'),
|
||||||
method: yup.string().required(),
|
method: yup.string().required(),
|
||||||
sender: yup.string().label('SMTP Sender').email().required(),
|
sender: yup.string().label('SMTP Sender').required(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,20 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
import { EditUserPasswordForm } from '@/features/orgs/projects/authentication/users/components/EditUserPasswordForm';
|
import { EditUserPasswordForm } from '@/features/orgs/projects/authentication/users/components/EditUserPasswordForm';
|
||||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||||
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import { copy } from '@/utils/copy';
|
|
||||||
import {
|
import {
|
||||||
RemoteAppGetUsersDocument,
|
RemoteAppGetUsersDocument,
|
||||||
useGetProjectLocalesQuery,
|
useGetProjectLocalesQuery,
|
||||||
useGetRolesPermissionsQuery,
|
useGetRolesPermissionsQuery,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { copy } from '@/utils/copy';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useTheme } from '@mui/material';
|
import { useTheme } from '@mui/material';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -106,6 +108,8 @@ export default function EditUserForm({
|
|||||||
onDeleteUser,
|
onDeleteUser,
|
||||||
roles,
|
roles,
|
||||||
}: EditUserFormProps) {
|
}: EditUserFormProps) {
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const localMimirClient = useLocalMimirClient();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { onDirtyStateChange, openDialog } = useDialog();
|
const { onDirtyStateChange, openDialog } = useDialog();
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
@@ -196,6 +200,7 @@ export default function EditUserForm({
|
|||||||
|
|
||||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||||
variables: { appId: project?.id },
|
variables: { appId: project?.id },
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const allAvailableProjectRoles = getUserRoles(
|
const allAvailableProjectRoles = getUserRoles(
|
||||||
@@ -206,6 +211,7 @@ export default function EditUserForm({
|
|||||||
variables: {
|
variables: {
|
||||||
appId: project?.id,
|
appId: project?.id,
|
||||||
},
|
},
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||||
@@ -61,6 +63,8 @@ export interface UsersBodyProps {
|
|||||||
|
|
||||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const localMimirClient = useLocalMimirClient();
|
||||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
@@ -88,6 +92,7 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
|||||||
*/
|
*/
|
||||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||||
variables: { appId: project?.id },
|
variables: { appId: project?.id },
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export default function ApplicationPaused() {
|
|||||||
>
|
>
|
||||||
<RemoveApplicationModal
|
<RemoveApplicationModal
|
||||||
close={() => setShowDeletingModal(false)}
|
close={() => setShowDeletingModal(false)}
|
||||||
title={`Remove project ${project.name}?`}
|
title={`Remove project ${project?.name}?`}
|
||||||
description={`The project ${project.name} will be removed. All data will be lost and there will be no way to
|
description={`The project ${project?.name} will be removed. All data will be lost and there will be no way to
|
||||||
recover the app once it has been deleted.`}
|
recover the app once it has been deleted.`}
|
||||||
className="z-50"
|
className="z-50"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
||||||
import { ApplicationStatus } from '@/types/application';
|
import { ApplicationStatus } from '@/types/application';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +9,7 @@ export default function useAppState(): {
|
|||||||
state: ApplicationStatus;
|
state: ApplicationStatus;
|
||||||
message?: string;
|
message?: string;
|
||||||
} {
|
} {
|
||||||
const { project } = useProject({ poll: true });
|
const { project } = useProjectWithState();
|
||||||
const noApplication = !project;
|
const noApplication = !project;
|
||||||
|
|
||||||
if (noApplication) {
|
if (noApplication) {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useIsFileStoreSupported } from './useIsFileStoreSupported';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { useGetConfiguredVersionsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function compareSemver(v1: string, v2: string): number {
|
||||||
|
const parse = (v: string) => v.split('.').map(Number);
|
||||||
|
const [a, b] = [parse(v1), parse(v2)];
|
||||||
|
for (let i = 0; i < 3; i += 1) {
|
||||||
|
if (a[i] > b[i]) { return 1; }
|
||||||
|
if (a[i] < b[i]) { return -1; }
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_VERSION_WITH_FILE_STORE_SUPPORT = '0.6.2';
|
||||||
|
|
||||||
|
export default function useIsFileStoreSupported() {
|
||||||
|
const [isFileStoreSupported, setIsFileStoreSupported] = useState<boolean | null>(null);
|
||||||
|
const { project } = useProject();
|
||||||
|
const localMimirClient = useLocalMimirClient();
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
|
||||||
|
const { data, loading, error } = useGetConfiguredVersionsQuery({
|
||||||
|
variables: { appId: project?.id },
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && data?.config?.ai?.version) {
|
||||||
|
setIsFileStoreSupported(compareSemver(data.config.ai.version, MIN_VERSION_WITH_FILE_STORE_SUPPORT) >= 0);
|
||||||
|
}
|
||||||
|
}, [data, loading]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isFileStoreSupported,
|
||||||
|
version: data?.config?.ai?.version,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export default function useProjectRedirectWhenReady(
|
|||||||
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
||||||
...options,
|
...options,
|
||||||
variables: { ...options.variables, appId: project?.id },
|
variables: { ...options.variables, appId: project?.id },
|
||||||
skip: !project.id,
|
skip: !project?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
|||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...args}
|
{...args}
|
||||||
name="firstReference"
|
name="firstReference"
|
||||||
label="First Reference"
|
onChange={(newValue) =>
|
||||||
onChange={(_event, newValue) =>
|
|
||||||
form.setValue('firstReference', newValue.value, {
|
form.setValue('firstReference', newValue.value, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
})
|
})
|
||||||
@@ -80,8 +79,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
|||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...args}
|
{...args}
|
||||||
name="secondReference"
|
name="secondReference"
|
||||||
label="Second Reference"
|
onChange={(newValue) =>
|
||||||
onChange={(_event, newValue) =>
|
|
||||||
form.setValue('secondReference', newValue.value, {
|
form.setValue('secondReference', newValue.value, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { setupServer } from 'msw/node';
|
|||||||
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
||||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils', () => ({
|
||||||
|
cn: (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
const server = setupServer(
|
const server = setupServer(
|
||||||
tableQuery,
|
tableQuery,
|
||||||
hasuraMetadataQuery,
|
hasuraMetadataQuery,
|
||||||
@@ -21,17 +25,9 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render a combobox', () => {
|
test('should render a combobox', () => {
|
||||||
render(
|
render(<ColumnAutocomplete schema="public" table="books" />);
|
||||||
<ColumnAutocomplete
|
|
||||||
schema="public"
|
|
||||||
table="books"
|
|
||||||
label="Column Autocomplete"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
screen.getByRole('combobox', { name: /column autocomplete/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Network requests don't go through in tests, so we can't test the
|
// Note: Network requests don't go through in tests, so we can't test the
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import {
|
||||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
Command,
|
||||||
import { AutocompletePopper } from '@/components/ui/v2/Autocomplete';
|
CommandEmpty,
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
CommandGroup,
|
||||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
CommandInput,
|
||||||
import { ArrowLeftIcon } from '@/components/ui/v2/icons/ArrowLeftIcon';
|
CommandItem,
|
||||||
import type { InputProps } from '@/components/ui/v2/Input';
|
CommandList,
|
||||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
} from '@/components/ui/v3/command';
|
||||||
import { List } from '@/components/ui/v2/List';
|
import {
|
||||||
import { OptionBase } from '@/components/ui/v2/Option';
|
Popover,
|
||||||
import { OptionGroupBase } from '@/components/ui/v2/OptionGroup';
|
PopoverContent,
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
import { useMetadataQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useMetadataQuery';
|
import { useMetadataQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useMetadataQuery';
|
||||||
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
||||||
import { getTruncatedText } from '@/utils/getTruncatedText';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AutocompleteGroupedOption } from '@mui/base/useAutocomplete';
|
import { Check, ChevronLeft, ChevronsUpDown } from 'lucide-react';
|
||||||
import { useAutocomplete } from '@mui/base/useAutocomplete';
|
|
||||||
import type { AutocompleteRenderGroupParams } from '@mui/material/Autocomplete';
|
import useRuleGroupEditor from '@/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/useRuleGroupEditor';
|
||||||
import { autocompleteClasses } from '@mui/material/Autocomplete';
|
import { CommandLoading } from 'cmdk';
|
||||||
import type {
|
import type { ForwardedRef } from 'react';
|
||||||
ChangeEvent,
|
|
||||||
ForwardedRef,
|
|
||||||
HTMLAttributes,
|
|
||||||
PropsWithoutRef,
|
|
||||||
SyntheticEvent,
|
|
||||||
} from 'react';
|
|
||||||
import { forwardRef, useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useState } from 'react';
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import type { UseAsyncValueOptions } from './useAsyncValue';
|
import type { UseAsyncValueOptions } from './useAsyncValue';
|
||||||
import useAsyncValue from './useAsyncValue';
|
import useAsyncValue from './useAsyncValue';
|
||||||
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
||||||
import useColumnGroups from './useColumnGroups';
|
import useColumnGroups from './useColumnGroups';
|
||||||
|
|
||||||
export interface ColumnAutocompleteProps
|
export interface ColumnAutocompleteProps extends Omit<ButtonProps, 'onChange'> {
|
||||||
extends Omit<PropsWithoutRef<InputProps>, 'onChange'> {
|
value?: string;
|
||||||
/**
|
/**
|
||||||
* Schema where the `table` is located.
|
* Schema where the `table` is located.
|
||||||
*/
|
*/
|
||||||
@@ -45,70 +39,39 @@ export interface ColumnAutocompleteProps
|
|||||||
/**
|
/**
|
||||||
* Function to be called when the value changes.
|
* Function to be called when the value changes.
|
||||||
*/
|
*/
|
||||||
onChange?: (
|
onChange?: (value: {
|
||||||
event: SyntheticEvent,
|
value: string;
|
||||||
value: {
|
columnMetadata?: Record<string, any>;
|
||||||
value: string;
|
disableReset?: boolean;
|
||||||
columnMetadata?: Record<string, any>;
|
}) => void;
|
||||||
disableReset?: boolean;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
/**
|
/**
|
||||||
* Function to be called when the input is asynchronously initialized.
|
* Function to be called when the input is asynchronously initialized.
|
||||||
*/
|
*/
|
||||||
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
||||||
/**
|
|
||||||
* Class name to be applied to the root element.
|
|
||||||
*/
|
|
||||||
rootClassName?: string;
|
|
||||||
/**
|
/**
|
||||||
* Determines if the autocomplete should allow relationships.
|
* Determines if the autocomplete should allow relationships.
|
||||||
*/
|
*/
|
||||||
disableRelationships?: UseColumnGroupsOptions['disableRelationships'];
|
disableRelationships?: UseColumnGroupsOptions['disableRelationships'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroup(params: AutocompleteRenderGroupParams) {
|
|
||||||
return (
|
|
||||||
<li key={params.key}>
|
|
||||||
<OptionGroupBase>{params.group}</OptionGroupBase>
|
|
||||||
|
|
||||||
<List>{params.children}</List>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOption(
|
|
||||||
option: AutocompleteOption<string>,
|
|
||||||
optionProps: HTMLAttributes<HTMLLIElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<OptionBase
|
|
||||||
{...optionProps}
|
|
||||||
className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5"
|
|
||||||
>
|
|
||||||
<Text component="span">{option.label}</Text>
|
|
||||||
|
|
||||||
{option.group === 'columns' && (
|
|
||||||
<InlineCode>{option.metadata?.udt_name || option.value}</InlineCode>
|
|
||||||
)}
|
|
||||||
</OptionBase>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColumnAutocomplete(
|
function ColumnAutocomplete(
|
||||||
{
|
{
|
||||||
rootClassName,
|
|
||||||
schema: defaultSchema,
|
schema: defaultSchema,
|
||||||
table: defaultTable,
|
table: defaultTable,
|
||||||
value: externalValue,
|
value: externalValue,
|
||||||
disableRelationships,
|
disableRelationships,
|
||||||
onChange,
|
onChange,
|
||||||
onInitialized,
|
onInitialized,
|
||||||
...props
|
|
||||||
}: ColumnAutocompleteProps,
|
}: ColumnAutocompleteProps,
|
||||||
ref: ForwardedRef<HTMLInputElement>,
|
ref: ForwardedRef<HTMLButtonElement>,
|
||||||
) {
|
) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
const { disabled } = useRuleGroupEditor();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const [activeRelationship, setActiveRelationship] = useState<{
|
const [activeRelationship, setActiveRelationship] = useState<{
|
||||||
schema: string;
|
schema: string;
|
||||||
table: string;
|
table: string;
|
||||||
@@ -120,7 +83,6 @@ function ColumnAutocomplete(
|
|||||||
const {
|
const {
|
||||||
data: tableData,
|
data: tableData,
|
||||||
status: tableStatus,
|
status: tableStatus,
|
||||||
error: tableError,
|
|
||||||
isFetching: isTableFetching,
|
isFetching: isTableFetching,
|
||||||
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
||||||
schema: selectedSchema,
|
schema: selectedSchema,
|
||||||
@@ -132,7 +94,6 @@ function ColumnAutocomplete(
|
|||||||
const {
|
const {
|
||||||
data: metadata,
|
data: metadata,
|
||||||
status: metadataStatus,
|
status: metadataStatus,
|
||||||
error: metadataError,
|
|
||||||
isFetching: isMetadataFetching,
|
isFetching: isMetadataFetching,
|
||||||
} = useMetadataQuery([`default.metadata`], {
|
} = useMetadataQuery([`default.metadata`], {
|
||||||
queryOptions: { refetchOnWindowFocus: false },
|
queryOptions: { refetchOnWindowFocus: false },
|
||||||
@@ -140,8 +101,6 @@ function ColumnAutocomplete(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
initialized,
|
initialized,
|
||||||
inputValue,
|
|
||||||
setInputValue,
|
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
setSelectedColumn,
|
setSelectedColumn,
|
||||||
selectedRelationships,
|
selectedRelationships,
|
||||||
@@ -159,57 +118,20 @@ function ColumnAutocomplete(
|
|||||||
onInitialized,
|
onInitialized,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPages(
|
||||||
|
relationshipDotNotation ? [relationshipDotNotation?.split('.')[0]] : [],
|
||||||
|
);
|
||||||
|
}, [relationshipDotNotation]);
|
||||||
|
|
||||||
|
const activePage = pages[pages.length - 1];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveRelationship(asyncActiveRelationship);
|
setActiveRelationship(asyncActiveRelationship);
|
||||||
}, [asyncActiveRelationship]);
|
}, [asyncActiveRelationship]);
|
||||||
|
|
||||||
function isOptionEqualToValue(
|
|
||||||
option: AutocompleteOption,
|
|
||||||
value: NonNullable<string | AutocompleteOption>,
|
|
||||||
) {
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return option.value === value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return option.value === value.value && option.custom === value.custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(
|
|
||||||
event: SyntheticEvent,
|
|
||||||
value: NonNullable<string | AutocompleteOption>,
|
|
||||||
) {
|
|
||||||
if (typeof value === 'string' || Array.isArray(value) || !value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('group' in value && value.group === 'columns') {
|
|
||||||
setSelectedColumn(value);
|
|
||||||
setOpen(false);
|
|
||||||
setInputValue(value.value);
|
|
||||||
|
|
||||||
onChange?.(event, {
|
|
||||||
value:
|
|
||||||
selectedRelationships.length > 0
|
|
||||||
? [relationshipDotNotation, value.value].join('.')
|
|
||||||
: value.value,
|
|
||||||
columnMetadata: value.metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue('');
|
|
||||||
setSelectedColumn(null);
|
|
||||||
setSelectedRelationships((currentRelationships) => [
|
|
||||||
...currentRelationships,
|
|
||||||
value.metadata?.target,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = useColumnGroups({
|
const options = useColumnGroups({
|
||||||
selectedSchema,
|
selectedSchema,
|
||||||
selectedTable,
|
selectedTable,
|
||||||
@@ -218,246 +140,214 @@ function ColumnAutocomplete(
|
|||||||
disableRelationships,
|
disableRelationships,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const handleChange = (newValue: string) => {
|
||||||
popupOpen,
|
const selectedOption = options.find((option) => option.value === newValue);
|
||||||
anchorEl,
|
|
||||||
setAnchorEl,
|
|
||||||
getRootProps,
|
|
||||||
getInputLabelProps,
|
|
||||||
getInputProps,
|
|
||||||
getListboxProps,
|
|
||||||
getOptionProps,
|
|
||||||
groupedOptions,
|
|
||||||
} = useAutocomplete({
|
|
||||||
open,
|
|
||||||
inputValue,
|
|
||||||
options,
|
|
||||||
id: props?.name,
|
|
||||||
openOnFocus: !props.disabled,
|
|
||||||
disableCloseOnSelect: true,
|
|
||||||
value: selectedColumn,
|
|
||||||
onClose: () => setOpen(false),
|
|
||||||
groupBy: (option) => option.group,
|
|
||||||
isOptionEqualToValue,
|
|
||||||
onChange: handleChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInputValueChange(
|
if (!selectedOption) {
|
||||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
return;
|
||||||
) {
|
}
|
||||||
const { value } = event.target;
|
|
||||||
setInputValue(value);
|
|
||||||
|
|
||||||
setSelectedColumn({
|
setSelectedColumn(selectedOption);
|
||||||
value,
|
setOpen(false);
|
||||||
label: value,
|
setValue(newValue === value ? '' : newValue);
|
||||||
metadata: selectedColumn?.metadata || {
|
|
||||||
table_schema: selectedSchema,
|
|
||||||
table_name: selectedTable,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onChange?.(event, {
|
const valueObj = {
|
||||||
value:
|
value:
|
||||||
selectedRelationships.length > 0
|
selectedRelationships.length > 0
|
||||||
? [relationshipDotNotation, value].join('.')
|
? [relationshipDotNotation, newValue].join('.')
|
||||||
: value,
|
: newValue,
|
||||||
columnMetadata: {
|
columnMetadata: selectedOption.metadata,
|
||||||
table_schema: selectedSchema,
|
};
|
||||||
table_name: selectedTable,
|
|
||||||
},
|
onChange?.(valueObj);
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
const handleRelationshipChange = (newValue: string) => {
|
||||||
|
const selectedOption = options.find((option) => option.value === newValue);
|
||||||
|
|
||||||
|
if (!selectedOption) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPages((p) => [...p, newValue]);
|
||||||
|
setSelectedColumn(null);
|
||||||
|
setSearch('');
|
||||||
|
setSelectedRelationships((currentRelationships) => [
|
||||||
|
...currentRelationships,
|
||||||
|
selectedOption.metadata?.target,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = options.filter((option) => option.group === 'columns');
|
||||||
|
const relationships = options.filter(
|
||||||
|
(option) => option.group === 'relationships',
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackRelationship = () => {
|
||||||
|
setPages((p) => p.slice(0, -1));
|
||||||
|
setSelectedColumn(null);
|
||||||
|
setSelectedRelationships((activeRelationships) =>
|
||||||
|
activeRelationships.slice(0, -1),
|
||||||
|
);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonPrefix = relationshipDotNotation
|
||||||
|
? `${selectedTable}.${relationshipDotNotation}`
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<div {...getRootProps()} className={rootClassName}>
|
<PopoverTrigger asChild>
|
||||||
<Input
|
<Button
|
||||||
{...props}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
fullWidth
|
disabled={disabled}
|
||||||
slotProps={{
|
variant="outline"
|
||||||
...(props.slotProps || {}),
|
role="combobox"
|
||||||
label: getInputLabelProps(),
|
aria-expanded={open}
|
||||||
input: {
|
className="justify-between"
|
||||||
...(props.slotProps?.input || {}),
|
>
|
||||||
ref: setAnchorEl,
|
{buttonPrefix ? (
|
||||||
sx: [
|
<div className="flex flex-shrink-0 gap-0 truncate">
|
||||||
...(Array.isArray(props.slotProps?.input?.sx)
|
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||||
? props.slotProps.input.sx
|
{buttonPrefix}.
|
||||||
: [props.slotProps?.input?.sx || {}]),
|
</span>
|
||||||
{
|
{selectedColumn?.label}
|
||||||
[`& .${inputClasses.input}`]: {
|
</div>
|
||||||
backgroundColor: 'transparent',
|
) : (
|
||||||
},
|
selectedColumn?.label || 'Select a column'
|
||||||
},
|
)}
|
||||||
],
|
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
|
||||||
},
|
</Button>
|
||||||
inputRoot: {
|
</PopoverTrigger>
|
||||||
...getInputProps(),
|
<PopoverContent
|
||||||
className: twMerge(
|
side="bottom"
|
||||||
Boolean(selectedColumn) || Boolean(relationshipDotNotation)
|
align="start"
|
||||||
? '!pl-0'
|
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
: null,
|
|
||||||
props.slotProps?.inputRoot?.className,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
error={Boolean(tableError || metadataError) || props.error}
|
|
||||||
helperText={
|
|
||||||
String(tableError || metadataError || '') || props.helperText
|
|
||||||
}
|
|
||||||
onChange={handleInputValueChange}
|
|
||||||
value={inputValue}
|
|
||||||
startAdornment={
|
|
||||||
selectedColumn || relationshipDotNotation ? (
|
|
||||||
<Text
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
color: props.disabled ? 'text.disabled' : 'text.primary',
|
|
||||||
}}
|
|
||||||
className="!ml-2 flex-shrink-0 truncate lg:max-w-[200px]"
|
|
||||||
>
|
|
||||||
<Text component="span" color="disabled">
|
|
||||||
{selectedTable}
|
|
||||||
</Text>
|
|
||||||
.
|
|
||||||
{relationshipDotNotation && (
|
|
||||||
<>
|
|
||||||
<span className="hidden lg:inline">
|
|
||||||
{getTruncatedText(relationshipDotNotation, 15, 'end')}.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="inline lg:hidden">
|
|
||||||
{getTruncatedText(relationshipDotNotation, 35, 'end')}.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
endAdornment={
|
|
||||||
tableStatus === 'loading' ||
|
|
||||||
metadataStatus === 'loading' ||
|
|
||||||
!initialized ? (
|
|
||||||
<ActivityIndicator className="mr-2" delay={500} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AutocompletePopper
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
modifiers={[{ name: 'offset', options: { offset: [0, 10] } }]}
|
|
||||||
placement="bottom-start"
|
|
||||||
open={popupOpen}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
style={{ width: anchorEl?.parentElement?.clientWidth }}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Command
|
||||||
className={autocompleteClasses.paper}
|
onKeyDown={(e) => {
|
||||||
sx={{
|
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||||
borderWidth: (theme) => (theme.palette.mode === 'dark' ? 1 : 0),
|
e.preventDefault();
|
||||||
borderColor: (theme) =>
|
setPages((p) => p.slice(0, -1));
|
||||||
theme.palette.mode === 'dark' ? 'grey.400' : 'none',
|
setSelectedColumn(null);
|
||||||
|
setSelectedRelationships((activeRelationships) =>
|
||||||
|
activeRelationships.slice(0, -1),
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<CommandInput
|
||||||
className="grid grid-flow-col items-center justify-start gap-2 border-b-1 px-3 py-2.5"
|
value={search}
|
||||||
sx={{ backgroundColor: 'transparent' }}
|
onValueChange={setSearch}
|
||||||
>
|
autoFocus
|
||||||
{selectedRelationships.length > 0 && (
|
placeholder=""
|
||||||
<IconButton
|
prefix={
|
||||||
variant="borderless"
|
relationshipDotNotation
|
||||||
color="secondary"
|
? `
|
||||||
onClick={(event) => {
|
${selectedTable}.${relationshipDotNotation}.`
|
||||||
event.stopPropagation();
|
: ``
|
||||||
|
}
|
||||||
setInputValue('');
|
/>
|
||||||
setSelectedColumn(null);
|
{pages?.length > 0 ? (
|
||||||
setSelectedRelationships((activeRelationships) =>
|
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
|
||||||
activeRelationships.slice(0, -1),
|
<Button
|
||||||
);
|
variant="outline"
|
||||||
}}
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleBackRelationship}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</IconButton>
|
</Button>
|
||||||
)}
|
<span className="py-1.5 text-sm text-muted-foreground">
|
||||||
|
{defaultTable}.{pages.join('.')}
|
||||||
<Text className="direction-rtl truncate text-left">
|
</span>
|
||||||
<Text component="span" color="disabled">
|
|
||||||
{defaultTable}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{relationshipDotNotation && (
|
|
||||||
<>
|
|
||||||
<span className="hidden lg:inline">
|
|
||||||
.{getTruncatedText(relationshipDotNotation, 20, 'start')}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="inline lg:hidden">
|
|
||||||
.{relationshipDotNotation}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{(tableStatus === 'loading' ||
|
|
||||||
metadataStatus === 'loading' ||
|
|
||||||
!initialized) && (
|
|
||||||
<div className="p-2">
|
|
||||||
<ActivityIndicator label="Loading..." />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
<CommandList>
|
||||||
{groupedOptions.length > 0 && (
|
{!activePage && (
|
||||||
<List
|
<>
|
||||||
{...getListboxProps()}
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
className={autocompleteClasses.listbox}
|
{tableStatus === 'loading' ||
|
||||||
>
|
metadataStatus === 'loading' ||
|
||||||
{(
|
(!initialized && <CommandLoading>Loading...</CommandLoading>)}
|
||||||
groupedOptions as AutocompleteGroupedOption<
|
<CommandGroup heading="columns">
|
||||||
(typeof options)[number]
|
{columns.map((option) => (
|
||||||
>[]
|
<CommandItem
|
||||||
).map((optionGroup) =>
|
key={option.value}
|
||||||
renderGroup({
|
value={option.value}
|
||||||
key: `${optionGroup.key}`,
|
onSelect={handleChange}
|
||||||
group: optionGroup.group,
|
className="overflow-x-hidden"
|
||||||
children: optionGroup.options.map((option, index) =>
|
>
|
||||||
renderOption(
|
<Check
|
||||||
option,
|
className={cn(
|
||||||
getOptionProps({
|
'mr-2 h-4 w-4',
|
||||||
option,
|
value === option.value ? 'opacity-100' : 'opacity-0',
|
||||||
index: optionGroup.index + index,
|
)}
|
||||||
}),
|
/>
|
||||||
),
|
<div className="flex gap-3">
|
||||||
),
|
<span className="line-clamp-2 break-all">
|
||||||
}),
|
{option.label}
|
||||||
)}
|
</span>
|
||||||
</List>
|
<div className="flex items-center">
|
||||||
)}
|
<code className="relative rounded bg-primary px-[0.2rem] font-mono text-white">
|
||||||
|
{option.metadata?.udt_name || option.value}
|
||||||
{groupedOptions.length === 0 && Boolean(anchorEl) && (
|
</code>
|
||||||
<Text className={autocompleteClasses.noOptions}>No options</Text>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Box>
|
</CommandItem>
|
||||||
</AutocompletePopper>
|
))}
|
||||||
</>
|
</CommandGroup>
|
||||||
|
{relationships.length > 0 && !disableRelationships && (
|
||||||
|
<CommandGroup heading="relationships">
|
||||||
|
{relationships.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={handleRelationshipChange}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activePage && (
|
||||||
|
<>
|
||||||
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
|
<CommandGroup heading="columns">
|
||||||
|
{columns.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={handleChange}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
value === option.value ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="line-clamp-2 break-all">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<code className="relative rounded bg-primary px-[0.2rem] font-mono text-white">
|
||||||
|
{option.metadata?.udt_name || option.value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export default function useAsyncValue({
|
|||||||
onInitialized,
|
onInitialized,
|
||||||
}: UseAsyncValueOptions) {
|
}: UseAsyncValueOptions) {
|
||||||
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
// We might not going to have the most up-to-date table data because the
|
// We might not going to have the most up-to-date table data because the
|
||||||
// relationship is loaded asynchronously, so we need to make sure we don't
|
// relationship is loaded asynchronously, so we need to make sure we don't
|
||||||
@@ -131,7 +130,6 @@ export default function useAsyncValue({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
||||||
setInputValue(activeColumn);
|
|
||||||
}, [
|
}, [
|
||||||
remainingColumnPath,
|
remainingColumnPath,
|
||||||
isTableLoading,
|
isTableLoading,
|
||||||
@@ -287,8 +285,6 @@ export default function useAsyncValue({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
initialized,
|
initialized,
|
||||||
inputValue,
|
|
||||||
setInputValue,
|
|
||||||
activeRelationship,
|
activeRelationship,
|
||||||
selectedRelationships: initialized ? selectedRelationships : [],
|
selectedRelationships: initialized ? selectedRelationships : [],
|
||||||
selectedColumn: initialized ? selectedColumn : null,
|
selectedColumn: initialized ? selectedColumn : null,
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
|
||||||
import { DataGrid } from '@/components/dataGrid/DataGrid';
|
|
||||||
import { DataGridBooleanCell } from '@/components/dataGrid/DataGridBooleanCell';
|
|
||||||
import { DataGridDateCell } from '@/components/dataGrid/DataGridDateCell';
|
|
||||||
import { DataGridNumericCell } from '@/components/dataGrid/DataGridNumericCell';
|
|
||||||
import { DataGridTextCell } from '@/components/dataGrid/DataGridTextCell';
|
|
||||||
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
||||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||||
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
||||||
@@ -23,11 +17,19 @@ import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGri
|
|||||||
import {
|
import {
|
||||||
POSTGRESQL_CHARACTER_TYPES,
|
POSTGRESQL_CHARACTER_TYPES,
|
||||||
POSTGRESQL_DATE_TIME_TYPES,
|
POSTGRESQL_DATE_TIME_TYPES,
|
||||||
|
POSTGRESQL_DECIMAL_TYPES,
|
||||||
|
POSTGRESQL_INTEGER_TYPES,
|
||||||
POSTGRESQL_JSON_TYPES,
|
POSTGRESQL_JSON_TYPES,
|
||||||
POSTGRESQL_NUMERIC_TYPES,
|
|
||||||
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||||
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
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';
|
||||||
|
import { DataGridDateCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDateCell';
|
||||||
|
import { DataGridDecimalCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDecimalCell';
|
||||||
|
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 { useQueryClient } from '@tanstack/react-query';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -68,10 +70,10 @@ export function createDataGridColumn(
|
|||||||
|
|
||||||
const defaultColumnConfiguration = {
|
const defaultColumnConfiguration = {
|
||||||
Header: () => (
|
Header: () => (
|
||||||
<div className="grid items-center justify-start grid-flow-col gap-1 font-normal">
|
<div className="grid grid-flow-col items-center justify-start gap-1 font-normal">
|
||||||
{column.is_primary && <KeyIcon className="text-sm" />}
|
{column.is_primary && <KeyIcon className="text-sm" />}
|
||||||
|
|
||||||
<span className="font-bold truncate" title={column.column_name}>
|
<span className="truncate font-bold" title={column.column_name}>
|
||||||
{column.column_name}
|
{column.column_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -104,12 +106,21 @@ export function createDataGridColumn(
|
|||||||
foreignKeyRelation: column.foreign_key_relation,
|
foreignKeyRelation: column.foreign_key_relation,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (POSTGRESQL_NUMERIC_TYPES.includes(column.data_type)) {
|
if (POSTGRESQL_INTEGER_TYPES.includes(column.data_type)) {
|
||||||
return {
|
return {
|
||||||
...defaultColumnConfiguration,
|
...defaultColumnConfiguration,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
width: 200,
|
width: 200,
|
||||||
Cell: DataGridNumericCell,
|
Cell: DataGridIntegerCell,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (POSTGRESQL_DECIMAL_TYPES.includes(column.data_type)) {
|
||||||
|
return {
|
||||||
|
...defaultColumnConfiguration,
|
||||||
|
type: 'text',
|
||||||
|
width: 200,
|
||||||
|
Cell: DataGridDecimalCell,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ export default function DatabaseRecordInputGroup({
|
|||||||
autoFocus={index === 0 && autoFocusFirstInput}
|
autoFocus={index === 0 && autoFocusFirstInput}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
label: commonLabelProps,
|
label: commonLabelProps,
|
||||||
inputRoot: { step: 1 },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -326,10 +326,10 @@ export default function RolePermissionEditorForm({
|
|||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
{error && error instanceof Error && (
|
{error && error instanceof Error && (
|
||||||
<div className="px-6 mb-4 -mt-3">
|
<div className="-mt-3 mb-4 px-6">
|
||||||
<Alert
|
<Alert
|
||||||
severity="error"
|
severity="error"
|
||||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
<strong>Error:</strong> {error.message}
|
<strong>Error:</strong> {error.message}
|
||||||
@@ -349,13 +349,13 @@ export default function RolePermissionEditorForm({
|
|||||||
|
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex flex-col content-between flex-auto overflow-hidden border-t-1"
|
className="flex flex-auto flex-col content-between overflow-hidden border-t-1"
|
||||||
sx={{ backgroundColor: 'background.default' }}
|
sx={{ backgroundColor: 'background.default' }}
|
||||||
>
|
>
|
||||||
<div className="grid content-start flex-auto grid-flow-row gap-6 py-4 overflow-auto">
|
<div className="grid flex-auto grid-flow-row content-start gap-6 overflow-auto py-4">
|
||||||
<PermissionSettingsSection
|
<PermissionSettingsSection
|
||||||
title="Selected role & action"
|
title="Selected role & action"
|
||||||
className="justify-between grid-flow-col"
|
className="grid-flow-col justify-between"
|
||||||
>
|
>
|
||||||
<div className="grid grid-flow-col gap-4">
|
<div className="grid grid-flow-col gap-4">
|
||||||
<Text>
|
<Text>
|
||||||
@@ -408,7 +408,7 @@ export default function RolePermissionEditorForm({
|
|||||||
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Box className="grid flex-shrink-0 gap-2 p-2 border-t-1 sm:grid-flow-col sm:justify-between">
|
<Box className="grid flex-shrink-0 gap-2 border-t-1 p-2 sm:grid-flow-col sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
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 type { HasuraOperator } from '@/features/database/dataGrid/types/dataBrowser';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
const commonOperators: {
|
||||||
|
value: HasuraOperator;
|
||||||
|
label?: string;
|
||||||
|
helperText?: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: '_eq', helperText: 'equal' },
|
||||||
|
{ value: '_neq', helperText: 'not equal' },
|
||||||
|
{ value: '_in', helperText: 'in (array)' },
|
||||||
|
{ value: '_nin', helperText: 'not in (array)' },
|
||||||
|
{ value: '_gt', helperText: 'greater than' },
|
||||||
|
{ value: '_lt', helperText: 'lower than' },
|
||||||
|
{ value: '_gte', helperText: 'greater than or equal' },
|
||||||
|
{ value: '_lte', helperText: 'lower than or equal' },
|
||||||
|
{ value: '_ceq', helperText: 'equal to column' },
|
||||||
|
{ value: '_cne', helperText: 'not equal to column' },
|
||||||
|
{ value: '_cgt', helperText: 'greater than column' },
|
||||||
|
{ value: '_clt', helperText: 'lower than column' },
|
||||||
|
{ value: '_cgte', helperText: 'greater than or equal to column' },
|
||||||
|
{ value: '_clte', helperText: 'lower than or equal to column' },
|
||||||
|
{ value: '_is_null', helperText: 'null' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const textSpecificOperators: typeof commonOperators = [
|
||||||
|
{ value: '_like', helperText: 'like' },
|
||||||
|
{ value: '_nlike', helperText: 'not like' },
|
||||||
|
{ value: '_ilike', helperText: 'like (case-insensitive)' },
|
||||||
|
{ value: '_nilike', helperText: 'not like (case-insensitive)' },
|
||||||
|
{ value: '_similar', helperText: 'similar' },
|
||||||
|
{ value: '_nsimilar', helperText: 'not similar' },
|
||||||
|
{ value: '_regex', helperText: 'matches regex' },
|
||||||
|
{ value: '_nregex', helperText: `doesn't match regex` },
|
||||||
|
{ value: '_iregex', helperText: 'matches case-insensitive regex' },
|
||||||
|
{ value: '_niregex', helperText: `doesn't match case-insensitive regex` },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface OperatorComboBoxProps {
|
||||||
|
name: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
selectedColumnType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OperatorComboBox({
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
selectedColumnType,
|
||||||
|
}: OperatorComboBoxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { watch, setValue } = useFormContext();
|
||||||
|
|
||||||
|
const operator = watch(`${name}.operator`);
|
||||||
|
|
||||||
|
const availableOperators = [
|
||||||
|
...commonOperators,
|
||||||
|
...(selectedColumnType === 'text' ? textSpecificOperators : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (['_in', '_nin'].includes(value)) {
|
||||||
|
setValue(`${name}.value`, [], { shouldDirty: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(`${name}.operator`, value, { shouldDirty: true });
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
|
{operator ?? 'Select operator...'}
|
||||||
|
<ChevronsUpDown className="h-5 w-5 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="bottom" align="start" className="p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search operator..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No operator found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableOperators.map((op) => (
|
||||||
|
<CommandItem
|
||||||
|
key={op.value}
|
||||||
|
keywords={[op.helperText]}
|
||||||
|
value={op.value}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className="flex flex-row justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<span className="min-w-[9ch]">{op.value}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{op.helperText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'ml-auto',
|
||||||
|
op.value === operator ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||||
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
|
||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useController, useFormContext } from 'react-hook-form';
|
import { useController, useFormContext } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import OperatorComboBox from './OperatorComboBox';
|
||||||
import RuleRemoveButton from './RuleRemoveButton';
|
import RuleRemoveButton from './RuleRemoveButton';
|
||||||
import RuleValueInput from './RuleValueInput';
|
import RuleValueInput from './RuleValueInput';
|
||||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
@@ -25,69 +22,6 @@ export interface RuleEditorRowProps
|
|||||||
* Function to be called when the remove button is clicked.
|
* Function to be called when the remove button is clicked.
|
||||||
*/
|
*/
|
||||||
onRemove?: VoidFunction;
|
onRemove?: VoidFunction;
|
||||||
/**
|
|
||||||
* List of operators to be disabled for the rule editor.
|
|
||||||
*
|
|
||||||
* @default []
|
|
||||||
*/
|
|
||||||
disabledOperators?: HasuraOperator[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonOperators: {
|
|
||||||
value: HasuraOperator;
|
|
||||||
label?: string;
|
|
||||||
helperText?: string;
|
|
||||||
}[] = [
|
|
||||||
{ value: '_eq', helperText: 'equal' },
|
|
||||||
{ value: '_neq', helperText: 'not equal' },
|
|
||||||
{ value: '_in_hasura', label: '_in', helperText: 'in (X-Hasura-)' },
|
|
||||||
{ value: '_in', helperText: 'in (array)' },
|
|
||||||
{ value: '_nin_hasura', label: '_nin', helperText: 'not in (X-Hasura-)' },
|
|
||||||
{ value: '_nin', helperText: 'not in (array)' },
|
|
||||||
{ value: '_gt', helperText: 'greater than' },
|
|
||||||
{ value: '_lt', helperText: 'lower than' },
|
|
||||||
{ value: '_gte', helperText: 'greater than or equal' },
|
|
||||||
{ value: '_lte', helperText: 'lower than or equal' },
|
|
||||||
{ value: '_ceq', helperText: 'equal to column' },
|
|
||||||
{ value: '_cne', helperText: 'not equal to column' },
|
|
||||||
{ value: '_cgt', helperText: 'greater than column' },
|
|
||||||
{ value: '_clt', helperText: 'lower than column' },
|
|
||||||
{ value: '_cgte', helperText: 'greater than or equal to column' },
|
|
||||||
{ value: '_clte', helperText: 'lower than or equal to column' },
|
|
||||||
{ value: '_is_null', helperText: 'null' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const textSpecificOperators: typeof commonOperators = [
|
|
||||||
{ value: '_like', helperText: 'like' },
|
|
||||||
{ value: '_nlike', helperText: 'not like' },
|
|
||||||
{ value: '_ilike', helperText: 'like (case-insensitive)' },
|
|
||||||
{ value: '_nilike', helperText: 'not like (case-insensitive)' },
|
|
||||||
{ value: '_similar', helperText: 'similar' },
|
|
||||||
{ value: '_nsimilar', helperText: 'not similar' },
|
|
||||||
{ value: '_regex', helperText: 'matches regex' },
|
|
||||||
{ value: '_nregex', helperText: `doesn't match regex` },
|
|
||||||
{ value: '_iregex', helperText: 'matches case-insensitive regex' },
|
|
||||||
{ value: '_niregex', helperText: `doesn't match case-insensitive regex` },
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderOption({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
helperText,
|
|
||||||
}: (typeof commonOperators)[number]) {
|
|
||||||
return (
|
|
||||||
<Option key={value} value={value} className="grid grid-flow-col gap-2">
|
|
||||||
<Text component="span" className="inline-block w-16">
|
|
||||||
{label || value}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{helperText && (
|
|
||||||
<Text component="span" color="disabled">
|
|
||||||
{helperText}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleEditorRow({
|
export default function RuleEditorRow({
|
||||||
@@ -95,17 +29,12 @@ export default function RuleEditorRow({
|
|||||||
index,
|
index,
|
||||||
onRemove,
|
onRemove,
|
||||||
className,
|
className,
|
||||||
disabledOperators = [],
|
|
||||||
...props
|
...props
|
||||||
}: RuleEditorRowProps) {
|
}: RuleEditorRowProps) {
|
||||||
const { schema, table, disabled } = useRuleGroupEditor();
|
const { schema, table } = useRuleGroupEditor();
|
||||||
const { control, setValue, getFieldState } = useFormContext();
|
const { control, setValue } = useFormContext();
|
||||||
const rowName = `${name}.rules.${index}`;
|
const rowName = `${name}.rules.${index}`;
|
||||||
|
|
||||||
const columnState = getFieldState(`${rowName}.column`);
|
|
||||||
const operatorState = getFieldState(`${rowName}.operator`);
|
|
||||||
const valueState = getFieldState(`${rowName}.value`);
|
|
||||||
|
|
||||||
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
||||||
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
||||||
const { field: autocompleteField } = useController({
|
const { field: autocompleteField } = useController({
|
||||||
@@ -113,48 +42,19 @@ export default function RuleEditorRow({
|
|||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const disabledOperatorMap = disabledOperators.reduce(
|
|
||||||
(map, currentOperator) => map.set(currentOperator, true),
|
|
||||||
new Map<string, boolean>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const availableOperators = [
|
|
||||||
...commonOperators.filter(({ value }) => !disabledOperatorMap.has(value)),
|
|
||||||
...(selectedColumnType === 'text'
|
|
||||||
? textSpecificOperators.filter(
|
|
||||||
({ value }) => !disabledOperatorMap.get(value),
|
|
||||||
)
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'grid grid-flow-row space-y-1 lg:max-h-10 lg:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] lg:space-y-0',
|
'flex flex-col gap-1 space-y-1 overflow-x-hidden pb-4 xl:grid xl:grid-flow-row xl:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] xl:space-y-0 xl:overflow-x-visible',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...autocompleteField}
|
{...autocompleteField}
|
||||||
disabled={disabled}
|
|
||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
rootClassName="h-10"
|
onChange={({ value, columnMetadata, disableReset }) => {
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
className: 'lg:!rounded-r-none',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark' ? 'grey.300' : 'common.white',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(columnState?.error?.message)}
|
|
||||||
onChange={(_event, { value, columnMetadata, disableReset }) => {
|
|
||||||
setSelectedTablePath(
|
setSelectedTablePath(
|
||||||
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
||||||
);
|
);
|
||||||
@@ -182,69 +82,21 @@ export default function RuleEditorRow({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<OperatorComboBox
|
||||||
<ControlledSelect
|
name={rowName}
|
||||||
disabled={disabled}
|
selectedColumnType={selectedColumnType}
|
||||||
name={`${rowName}.operator`}
|
/>
|
||||||
className="h-10"
|
|
||||||
slotProps={{
|
|
||||||
root: {
|
|
||||||
className: 'lg:!rounded-none',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
listbox: { className: 'max-h-[300px]' },
|
|
||||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(operatorState?.error?.message)}
|
|
||||||
onChange={(_event, value: HasuraOperator) => {
|
|
||||||
if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === '_in_hasura' || value === '_nin_hasura') {
|
|
||||||
setValue(`${rowName}.value`, null, {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue(`${rowName}.value`, [], { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
renderValue={(option) => {
|
|
||||||
if (!option?.value) {
|
|
||||||
return <span />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.value === '_in_hasura') {
|
|
||||||
return <span>_in</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.value === '_nin_hasura') {
|
|
||||||
return <span>_nin</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span>{option.value}</span>;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{availableOperators.map(renderOption)}
|
|
||||||
</ControlledSelect>
|
|
||||||
|
|
||||||
<RuleValueInput
|
<RuleValueInput
|
||||||
selectedTablePath={selectedTablePath}
|
selectedTablePath={selectedTablePath}
|
||||||
name={rowName}
|
name={rowName}
|
||||||
error={Boolean(valueState?.error?.message)}
|
className="min-h-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RuleRemoveButton onRemove={onRemove} name={name} disabled={disabled} />
|
<RuleRemoveButton
|
||||||
|
className="w-full xl:w-auto"
|
||||||
|
onRemove={onRemove}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/v3/select';
|
||||||
import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
import { useWatch } from 'react-hook-form';
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
|
|
||||||
@@ -32,9 +37,11 @@ export default function RuleGroupControls({
|
|||||||
...props
|
...props
|
||||||
}: RuleGroupControlsProps) {
|
}: RuleGroupControlsProps) {
|
||||||
const { disabled } = useRuleGroupEditor();
|
const { disabled } = useRuleGroupEditor();
|
||||||
|
const inputName = `${name}.operator`;
|
||||||
const currentOperator: RuleGroup['operator'] = useWatch({
|
const currentOperator: RuleGroup['operator'] = useWatch({
|
||||||
name: `${name}.operator`,
|
name: inputName,
|
||||||
});
|
});
|
||||||
|
const { setValue } = useFormContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -42,24 +49,26 @@ export default function RuleGroupControls({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showSelect ? (
|
{showSelect ? (
|
||||||
<ControlledSelect
|
<Select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={`${name}.operator`}
|
name={inputName}
|
||||||
slotProps={{
|
onValueChange={(newValue: string) => {
|
||||||
root: {
|
setValue(inputName, newValue, { shouldDirty: true });
|
||||||
sx: {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
fullWidth
|
defaultValue={currentOperator}
|
||||||
>
|
>
|
||||||
<Option value="_and">and</Option>
|
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||||
<Option value="_or">or</Option>
|
<SelectValue />
|
||||||
</ControlledSelect>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_and">
|
||||||
|
<span className="font-medium">and</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="_or">
|
||||||
|
<span className="font-medium">or</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Text className="p-2 !font-medium">
|
<Text className="p-2 !font-medium">
|
||||||
{operatorDictionary[currentOperator]}
|
{operatorDictionary[currentOperator]}
|
||||||
|
|||||||
@@ -89,9 +89,3 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
|
|||||||
export const Default = Template.bind({});
|
export const Default = Template.bind({});
|
||||||
Default.args = {};
|
Default.args = {};
|
||||||
Default.parameters = defaultParameters;
|
Default.parameters = defaultParameters;
|
||||||
|
|
||||||
export const DisabledOperators = Template.bind({});
|
|
||||||
DisabledOperators.args = {
|
|
||||||
disabledOperators: ['_in_hasura', '_nin_hasura', '_is_null'],
|
|
||||||
};
|
|
||||||
DisabledOperators.parameters = defaultParameters;
|
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ import { generateAppServiceUrl } from '@/features/projects/common/utils/generate
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { RuleEditorRowProps } from './RuleEditorRow';
|
|
||||||
import RuleEditorRow from './RuleEditorRow';
|
import RuleEditorRow from './RuleEditorRow';
|
||||||
import RuleGroupControls from './RuleGroupControls';
|
import RuleGroupControls from './RuleGroupControls';
|
||||||
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
||||||
|
|
||||||
export interface RuleGroupEditorProps
|
export interface RuleGroupEditorProps extends BoxProps {
|
||||||
extends BoxProps,
|
|
||||||
Pick<RuleEditorRowProps, 'disabledOperators'> {
|
|
||||||
/**
|
/**
|
||||||
* Determines whether or not the rule group editor is disabled.
|
* Determines whether or not the rule group editor is disabled.
|
||||||
*/
|
*/
|
||||||
@@ -63,7 +60,6 @@ export default function RuleGroupEditor({
|
|||||||
name,
|
name,
|
||||||
className,
|
className,
|
||||||
disableRemove,
|
disableRemove,
|
||||||
disabledOperators = [],
|
|
||||||
depth = 0,
|
depth = 0,
|
||||||
maxDepth,
|
maxDepth,
|
||||||
schema,
|
schema,
|
||||||
@@ -115,7 +111,7 @@ export default function RuleGroupEditor({
|
|||||||
<Box
|
<Box
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'rounded-lg border border-r-8 border-transparent pl-2',
|
'flex min-h-44 flex-col justify-between rounded-lg border border-r-8 border-transparent pl-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
sx={[
|
sx={[
|
||||||
@@ -147,7 +143,6 @@ export default function RuleGroupEditor({
|
|||||||
name={name}
|
name={name}
|
||||||
index={ruleIndex}
|
index={ruleIndex}
|
||||||
onRemove={() => removeRule(ruleIndex)}
|
onRemove={() => removeRule(ruleIndex)}
|
||||||
disabledOperators={disabledOperators}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -177,7 +172,6 @@ export default function RuleGroupEditor({
|
|||||||
table={table}
|
table={table}
|
||||||
onRemove={() => removeGroup(ruleGroupIndex)}
|
onRemove={() => removeGroup(ruleGroupIndex)}
|
||||||
disableRemove={rules.length === 0 && groups.length === 1}
|
disableRemove={rules.length === 0 && groups.length === 1}
|
||||||
disabledOperators={disabledOperators}
|
|
||||||
name={`${name}.groups.${ruleGroupIndex}`}
|
name={`${name}.groups.${ruleGroupIndex}`}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -247,7 +241,7 @@ export default function RuleGroupEditor({
|
|||||||
{onRemove && (
|
{onRemove && (
|
||||||
<Button
|
<Button
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
color="secondary"
|
color="error"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
disabled={disableRemove}
|
disabled={disableRemove}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
|
||||||
import type {
|
import type {
|
||||||
Rule,
|
Rule,
|
||||||
RuleGroup,
|
RuleGroup,
|
||||||
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { useWatch } from 'react-hook-form';
|
import { useWatch } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
@@ -34,9 +33,9 @@ function RuleRemoveButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outline"
|
||||||
color="secondary"
|
size="icon"
|
||||||
className={twMerge('h-10 !min-w-0 lg:!rounded-l-none', className)}
|
className={twMerge('h-10 !min-w-0', className)}
|
||||||
disabled={
|
disabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
(rules.length === 1 && !groups?.length && !unsupported?.length)
|
(rules.length === 1 && !groups?.length && !unsupported?.length)
|
||||||
@@ -44,18 +43,8 @@ function RuleRemoveButton({
|
|||||||
{...props}
|
{...props}
|
||||||
aria-label="Remove Rule"
|
aria-label="Remove Rule"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
sx={
|
|
||||||
!disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<XIcon className="!h-4 !w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
|
||||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
import { Button } from '@/components/ui/v3/button';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import {
|
||||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
Command,
|
||||||
import type { InputProps } from '@/components/ui/v2/Input';
|
CommandCreateItem,
|
||||||
import { inputClasses } from '@/components/ui/v2/Input';
|
CommandEmpty,
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
CommandGroup,
|
||||||
import type { ColumnAutocompleteProps } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
CommandInput,
|
||||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/v3/command';
|
||||||
|
import { FancyMultiSelect } from '@/components/ui/v3/fancy-multi-select';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/v3/select';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import {
|
||||||
|
ColumnAutocomplete,
|
||||||
|
type ColumnAutocompleteProps,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||||
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { getAllPermissionVariables } from '@/features/projects/permissions/settings/utils/getAllPermissionVariables';
|
import { getAllPermissionVariables } from '@/features/projects/permissions/settings/utils/getAllPermissionVariables';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { CommandLoading } from 'cmdk';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
|
|
||||||
@@ -41,23 +65,7 @@ function ColumnSelectorInput({
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
disableRelationships
|
disableRelationships
|
||||||
slotProps={{
|
onChange={({ value }) => {
|
||||||
input: {
|
|
||||||
className: 'lg:!rounded-none !z-10',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? theme.palette.grey[300]
|
|
||||||
: theme.palette.common.white,
|
|
||||||
[`& .${inputClasses.input}`]: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onChange={(_event, { value }) => {
|
|
||||||
if (selectedTablePath === `${schema}.${table}`) {
|
if (selectedTablePath === `${schema}.${table}`) {
|
||||||
setValue(name, [value], { shouldDirty: true });
|
setValue(name, [value], { shouldDirty: true });
|
||||||
return;
|
return;
|
||||||
@@ -75,113 +83,92 @@ export interface RuleValueInputProps {
|
|||||||
* Name of the parent group editor.
|
* Name of the parent group editor.
|
||||||
*/
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Class name to apply to the input wrapper.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Path of the table selected through the column input.
|
* Path of the table selected through the column input.
|
||||||
*/
|
*/
|
||||||
selectedTablePath?: string;
|
selectedTablePath?: string;
|
||||||
/**
|
|
||||||
* Whether the input should be marked as invalid.
|
|
||||||
*/
|
|
||||||
error?: InputProps['error'];
|
|
||||||
/**
|
|
||||||
* Helper text to display below the input.
|
|
||||||
*/
|
|
||||||
helperText?: InputProps['helperText'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleValueInput({
|
export default function RuleValueInput({
|
||||||
name,
|
name,
|
||||||
selectedTablePath,
|
selectedTablePath,
|
||||||
error,
|
className,
|
||||||
helperText,
|
|
||||||
}: RuleValueInputProps) {
|
}: RuleValueInputProps) {
|
||||||
const { schema, table, disabled } = useRuleGroupEditor();
|
const { schema, table, disabled } = useRuleGroupEditor();
|
||||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
const { project } = useProject();
|
||||||
const { setValue } = useFormContext();
|
const { setValue, control } = useFormContext();
|
||||||
const inputName = `${name}.value`;
|
const inputName = `${name}.value`;
|
||||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
const { field } = useController({
|
||||||
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
|
name: inputName,
|
||||||
const sharedInputSx: InputProps['sx'] = !disabled
|
control,
|
||||||
? {
|
});
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? theme.palette.grey[300]
|
|
||||||
: theme.palette.common.white,
|
|
||||||
[`& .${inputClasses.input}`]: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
const [open, setOpen] = useState(false);
|
||||||
data,
|
const comboboxValue = useWatch({ name: inputName });
|
||||||
loading,
|
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||||
error: customClaimsError,
|
|
||||||
} = useGetRolesPermissionsQuery({
|
const isPlatform = useIsPlatform();
|
||||||
variables: { appId: currentProject?.id },
|
const localMimirClient = useLocalMimirClient();
|
||||||
skip: !isHasuraInput || !currentProject?.id,
|
|
||||||
|
const { data, loading } = useGetRolesPermissionsQuery({
|
||||||
|
variables: { appId: project?.id },
|
||||||
|
skip: !project?.id,
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (operator === '_is_null') {
|
if (operator === '_is_null') {
|
||||||
|
const defaultValue = !Array.isArray(comboboxValue) ? comboboxValue : null;
|
||||||
return (
|
return (
|
||||||
<ControlledSelect
|
<Select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
fullWidth
|
onValueChange={(newValue: string) => {
|
||||||
slotProps={{
|
setValue(inputName, newValue, { shouldDirty: true });
|
||||||
root: {
|
|
||||||
className: 'lg:!rounded-none h-10',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
|
||||||
}}
|
}}
|
||||||
error={error}
|
defaultValue={defaultValue}
|
||||||
helperText={helperText}
|
|
||||||
>
|
>
|
||||||
<Option value="true">
|
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||||
<ReadOnlyToggle
|
<SelectValue placeholder="Is null?" />
|
||||||
checked
|
</SelectTrigger>
|
||||||
slotProps={{ label: { className: '!text-sm' } }}
|
<SelectContent>
|
||||||
/>
|
<SelectItem value="true">
|
||||||
</Option>
|
<span className="font-medium">true</span>
|
||||||
|
</SelectItem>
|
||||||
<Option value="false">
|
<SelectItem value="false">
|
||||||
<ReadOnlyToggle
|
<span className="font-medium">false</span>
|
||||||
checked={false}
|
</SelectItem>
|
||||||
slotProps={{ label: { className: '!text-sm' } }}
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
</Option>
|
|
||||||
</ControlledSelect>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||||
|
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||||
|
).map(({ key }) => ({
|
||||||
|
value: `X-Hasura-${key}`,
|
||||||
|
label: `X-Hasura-${key}`,
|
||||||
|
group: 'Frequently used',
|
||||||
|
}));
|
||||||
|
|
||||||
if (operator === '_in' || operator === '_nin') {
|
if (operator === '_in' || operator === '_nin') {
|
||||||
|
const defaultValue = Array.isArray(field.value) ? field.value : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<FancyMultiSelect
|
||||||
disabled={disabled}
|
className={className}
|
||||||
name={inputName}
|
options={availableHasuraPermissionVariables}
|
||||||
multiple
|
creatable
|
||||||
freeSolo
|
defaultValue={defaultValue.map((v) => ({ value: v, label: v }))}
|
||||||
limitTags={3}
|
onChange={(value) => {
|
||||||
slotProps={{
|
setValue(
|
||||||
input: {
|
inputName,
|
||||||
className: 'lg:!rounded-none !z-10',
|
value.map((v) => v.value),
|
||||||
sx: sharedInputSx,
|
{ shouldDirty: true },
|
||||||
},
|
);
|
||||||
paper: { className: 'hidden' },
|
|
||||||
}}
|
}}
|
||||||
options={[]}
|
|
||||||
fullWidth
|
|
||||||
filterSelectedOptions
|
|
||||||
error={error}
|
|
||||||
helperText={helperText}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -194,71 +181,70 @@ export default function RuleValueInput({
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
error={error}
|
|
||||||
helperText={helperText}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
const selectedVariable = availableHasuraPermissionVariables.find(
|
||||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
(variable) => variable.value === comboboxValue,
|
||||||
).map(({ key }) => ({
|
);
|
||||||
value: `X-Hasura-${key}`,
|
const comboboxLabel =
|
||||||
label: `X-Hasura-${key}`,
|
selectedVariable?.label || comboboxValue || 'Select variable...';
|
||||||
group: 'Frequently used',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
disabled={disabled}
|
<PopoverTrigger asChild>
|
||||||
freeSolo={!isHasuraInput}
|
<Button
|
||||||
autoHighlight={isHasuraInput}
|
variant="outline"
|
||||||
isOptionEqualToValue={(
|
role="combobox"
|
||||||
option,
|
aria-expanded={open}
|
||||||
value: string | number | AutocompleteOption<string>,
|
className="justify-between"
|
||||||
) => {
|
>
|
||||||
if (typeof value !== 'object') {
|
<span className="truncate">{comboboxLabel}</span>
|
||||||
return option.value.toLowerCase() === value?.toString().toLowerCase();
|
<ChevronsUpDown className="h-5 min-h-5 w-5 min-w-5 opacity-50" />
|
||||||
}
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
return option.value.toLowerCase() === value.value.toLowerCase();
|
<PopoverContent
|
||||||
}}
|
side="bottom"
|
||||||
name={inputName}
|
align="start"
|
||||||
groupBy={(option) => option.group}
|
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
slotProps={{
|
>
|
||||||
input: {
|
<Command>
|
||||||
className: 'lg:!rounded-none',
|
<CommandInput placeholder="Choose variable..." />
|
||||||
sx: sharedInputSx,
|
<CommandList>
|
||||||
},
|
<CommandEmpty>No variable found.</CommandEmpty>
|
||||||
formControl: { className: '!bg-transparent' },
|
{loading && <CommandLoading>Loading...</CommandLoading>}
|
||||||
paper: { className: 'empty:border-transparent' },
|
<CommandGroup>
|
||||||
}}
|
{availableHasuraPermissionVariables.map((variable) => (
|
||||||
fullWidth
|
<CommandItem
|
||||||
loading={loading}
|
key={variable.value}
|
||||||
loadingText={<ActivityIndicator label="Loading..." />}
|
value={variable.value}
|
||||||
error={Boolean(customClaimsError) || error}
|
onSelect={(currentValue) => {
|
||||||
helperText={customClaimsError?.message || helperText}
|
setValue(inputName, currentValue, { shouldDirty: true });
|
||||||
options={
|
setOpen(false);
|
||||||
isHasuraInput
|
}}
|
||||||
? availableHasuraPermissionVariables
|
>
|
||||||
: [
|
{variable.label}
|
||||||
{
|
<Check
|
||||||
value: 'X-Hasura-User-Id',
|
className={cn(
|
||||||
label: 'X-Hasura-User-Id',
|
'ml-auto',
|
||||||
group: 'Frequently used',
|
comboboxValue === variable.value
|
||||||
},
|
? 'opacity-100'
|
||||||
]
|
: 'opacity-0',
|
||||||
}
|
)}
|
||||||
onChange={(_event, _value, reason, details) => {
|
/>
|
||||||
if (
|
</CommandItem>
|
||||||
reason !== 'selectOption' &&
|
))}
|
||||||
details.option.value !== 'X-Hasura-User-Id'
|
</CommandGroup>
|
||||||
) {
|
<CommandCreateItem
|
||||||
return;
|
onCreate={(currentValue) => {
|
||||||
}
|
setValue(inputName, currentValue, { shouldDirty: true });
|
||||||
|
setOpen(false);
|
||||||
setValue(inputName, details.option.value, { shouldDirty: true });
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||||
import { getHasuraAdminSecret } from '@/utils/env';
|
import { getHasuraAdminSecret } from '@/utils/env';
|
||||||
@@ -39,10 +39,12 @@ export default function useUpdateColumnMutation({
|
|||||||
const {
|
const {
|
||||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
|
||||||
|
const { project } = useProject();
|
||||||
|
|
||||||
const appUrl = generateAppServiceUrl(
|
const appUrl = generateAppServiceUrl(
|
||||||
currentProject?.subdomain,
|
project?.subdomain,
|
||||||
currentProject?.region,
|
project?.region,
|
||||||
'hasura',
|
'hasura',
|
||||||
);
|
);
|
||||||
const mutationFn = isPlatform ? updateColumn : updateColumnMigration;
|
const mutationFn = isPlatform ? updateColumn : updateColumnMigration;
|
||||||
@@ -55,7 +57,7 @@ export default function useUpdateColumnMutation({
|
|||||||
adminSecret:
|
adminSecret:
|
||||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||||
? getHasuraAdminSecret()
|
? getHasuraAdminSecret()
|
||||||
: customAdminSecret || currentProject?.config?.hasura.adminSecret,
|
: customAdminSecret || project?.config?.hasura.adminSecret,
|
||||||
dataSource: customDataSource || (dataSourceSlug as string),
|
dataSource: customDataSource || (dataSourceSlug as string),
|
||||||
schema: customSchema || (schemaSlug as string),
|
schema: customSchema || (schemaSlug as string),
|
||||||
table: customTable || (tableSlug as string),
|
table: customTable || (tableSlug as string),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
import { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||||
import { getHasuraAdminSecret } from '@/utils/env';
|
import { getHasuraAdminSecret } from '@/utils/env';
|
||||||
import type { MutationOptions } from '@tanstack/react-query';
|
import type { MutationOptions } from '@tanstack/react-query';
|
||||||
@@ -40,10 +40,12 @@ export default function useUpdateRecordMutation<TData extends object = {}>({
|
|||||||
const {
|
const {
|
||||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
|
||||||
|
const { project } = useProject();
|
||||||
|
|
||||||
const appUrl = generateAppServiceUrl(
|
const appUrl = generateAppServiceUrl(
|
||||||
currentProject?.subdomain,
|
project?.subdomain,
|
||||||
currentProject?.region,
|
project?.region,
|
||||||
'hasura',
|
'hasura',
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -55,7 +57,7 @@ export default function useUpdateRecordMutation<TData extends object = {}>({
|
|||||||
adminSecret:
|
adminSecret:
|
||||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||||
? getHasuraAdminSecret()
|
? getHasuraAdminSecret()
|
||||||
: customAdminSecret || currentProject?.config?.hasura.adminSecret,
|
: customAdminSecret || project?.config?.hasura.adminSecret,
|
||||||
dataSource: customDataSource || (dataSourceSlug as string),
|
dataSource: customDataSource || (dataSourceSlug as string),
|
||||||
schema: customSchema || (schemaSlug as string),
|
schema: customSchema || (schemaSlug as string),
|
||||||
table: customTable || (tableSlug as string),
|
table: customTable || (tableSlug as string),
|
||||||
|
|||||||
@@ -544,9 +544,7 @@ export type HasuraOperator =
|
|||||||
| '_eq'
|
| '_eq'
|
||||||
| '_neq'
|
| '_neq'
|
||||||
| '_in'
|
| '_in'
|
||||||
| '_in_hasura'
|
|
||||||
| '_nin'
|
| '_nin'
|
||||||
| '_nin_hasura'
|
|
||||||
| '_gt'
|
| '_gt'
|
||||||
| '_lt'
|
| '_lt'
|
||||||
| '_gte'
|
| '_gte'
|
||||||
|
|||||||
@@ -202,36 +202,6 @@ test('should convert a complex permission to a rule group', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`should convert an _in or _nin value that do not have an array as value to _in_hasura or _nin_hasura`, () => {
|
|
||||||
expect(
|
|
||||||
convertToRuleGroup({ title: { _in: ['X-Hasura-Allowed-Ids'] } }),
|
|
||||||
).toMatchObject({
|
|
||||||
operator: '_and',
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
column: 'title',
|
|
||||||
operator: '_in',
|
|
||||||
value: ['X-Hasura-Allowed-Ids'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
convertToRuleGroup({ title: { _in: 'X-Hasura-Allowed-Ids' } }),
|
|
||||||
).toMatchObject({
|
|
||||||
operator: '_and',
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
column: 'title',
|
|
||||||
operator: '_in_hasura',
|
|
||||||
value: 'X-Hasura-Allowed-Ids',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should transform operators and relations if the _not operator is being used', () => {
|
test('should transform operators and relations if the _not operator is being used', () => {
|
||||||
expect(
|
expect(
|
||||||
convertToRuleGroup({ _not: { title: { _eq: 'test' } } }),
|
convertToRuleGroup({ _not: { title: { _eq: 'test' } } }),
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ const negatedValueOperatorPairs: Record<HasuraOperator, HasuraOperator> = {
|
|||||||
_cgte: '_clt',
|
_cgte: '_clt',
|
||||||
_clte: '_cgt',
|
_clte: '_cgt',
|
||||||
_is_null: '_is_null',
|
_is_null: '_is_null',
|
||||||
_in_hasura: '_nin_hasura',
|
|
||||||
_nin_hasura: '_in_hasura',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function convertToRuleGroup(
|
export default function convertToRuleGroup(
|
||||||
@@ -151,16 +149,14 @@ export default function convertToRuleGroup(
|
|||||||
(currentKey === '_in' || currentKey === '_nin') &&
|
(currentKey === '_in' || currentKey === '_nin') &&
|
||||||
typeof value === 'string'
|
typeof value === 'string'
|
||||||
) {
|
) {
|
||||||
const operator = currentKey === '_in' ? '_in_hasura' : '_nin_hasura';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
operator: '_and',
|
operator: '_and',
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
column: previousKey,
|
column: previousKey,
|
||||||
operator: shouldNegate
|
operator: shouldNegate
|
||||||
? negatedValueOperatorPairs[operator]
|
? negatedValueOperatorPairs[currentKey]
|
||||||
: operator,
|
: currentKey,
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,20 +19,23 @@ export const POSTGRESQL_ERROR_CODES = {
|
|||||||
*
|
*
|
||||||
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
|
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
|
||||||
*/
|
*/
|
||||||
export const POSTGRESQL_NUMERIC_TYPES = [
|
export const POSTGRESQL_INTEGER_TYPES = [
|
||||||
'smallint',
|
'smallint',
|
||||||
'integer',
|
'integer',
|
||||||
'bigint',
|
'bigint',
|
||||||
'decimal',
|
|
||||||
'numeric',
|
|
||||||
'real',
|
|
||||||
'double precision',
|
|
||||||
'smallserial',
|
'smallserial',
|
||||||
'serial',
|
'serial',
|
||||||
'bigserial',
|
'bigserial',
|
||||||
'oid',
|
'oid',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const POSTGRESQL_DECIMAL_TYPES = [
|
||||||
|
'decimal',
|
||||||
|
'numeric',
|
||||||
|
'real',
|
||||||
|
'double precision',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Character data types in PostgreSQL.
|
* Character data types in PostgreSQL.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ export default function DatabaseConnectionInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postgresHost = generateAppServiceUrl(
|
const postgresHost = generateAppServiceUrl(
|
||||||
project.subdomain,
|
project?.subdomain,
|
||||||
project.region,
|
project?.region,
|
||||||
'db',
|
'db',
|
||||||
).replace('https://', '');
|
).replace('https://', '');
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const validationSchema = Yup.object({
|
|||||||
value: Yup.string().required('Major version is a required field'),
|
value: Yup.string().required('Major version is a required field'),
|
||||||
})
|
})
|
||||||
.label('Postgres major version')
|
.label('Postgres major version')
|
||||||
.required(),
|
.required()
|
||||||
|
.test('not-zero', 'Invalid major version', (value) => value?.value !== '0'),
|
||||||
minorVersion: Yup.object({
|
minorVersion: Yup.object({
|
||||||
label: Yup.string().required(),
|
label: Yup.string().required(),
|
||||||
value: Yup.string().required('Minor version is a required field'),
|
value: Yup.string().required('Minor version is a required field'),
|
||||||
@@ -186,18 +187,29 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
shouldPoll: true,
|
shouldPoll: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showMigrateWarning =
|
|
||||||
Number(selectedMajor) > Number(currentPostgresMajor);
|
|
||||||
|
|
||||||
const { state } = useAppState();
|
const { state } = useAppState();
|
||||||
const applicationUpdating =
|
const applicationUpdating =
|
||||||
state === ApplicationStatus.Updating ||
|
state === ApplicationStatus.Updating ||
|
||||||
state === ApplicationStatus.Migrating;
|
state === ApplicationStatus.Migrating;
|
||||||
|
|
||||||
|
const applicationLive = state === ApplicationStatus.Live;
|
||||||
|
const applicationPaused = state === ApplicationStatus.Paused;
|
||||||
|
const applicationPausing = state === ApplicationStatus.Pausing;
|
||||||
|
|
||||||
|
const showMigrateWarning =
|
||||||
|
!applicationPaused &&
|
||||||
|
!applicationPausing &&
|
||||||
|
Number(selectedMajor) > Number(currentPostgresMajor);
|
||||||
|
|
||||||
const applicationUnhealthy =
|
const applicationUnhealthy =
|
||||||
state !== ApplicationStatus.Live && !applicationUpdating;
|
!applicationLive &&
|
||||||
|
!applicationPaused &&
|
||||||
|
!applicationPausing &&
|
||||||
|
!applicationUpdating;
|
||||||
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
||||||
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
||||||
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
||||||
|
|
||||||
const versionFieldsDisabled =
|
const versionFieldsDisabled =
|
||||||
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
||||||
const saveDisabled = versionFieldsDisabled || !isDirty;
|
const saveDisabled = versionFieldsDisabled || !isDirty;
|
||||||
@@ -208,7 +220,7 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
||||||
|
|
||||||
// Major version change
|
// Major version change
|
||||||
if (isMajorVersionDirty) {
|
if (isMajorVersionDirty && applicationLive) {
|
||||||
openDialog({
|
openDialog({
|
||||||
title: 'Update Postgres MAJOR version',
|
title: 'Update Postgres MAJOR version',
|
||||||
component: (
|
component: (
|
||||||
@@ -228,7 +240,7 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minor version change
|
// Only minor version change or project is paused/pausing
|
||||||
const updateConfigPromise = updateConfig({
|
const updateConfigPromise = updateConfig({
|
||||||
variables: {
|
variables: {
|
||||||
appId: project.id,
|
appId: project.id,
|
||||||
@@ -338,7 +350,6 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
return option.value;
|
return option.value;
|
||||||
}}
|
}}
|
||||||
showCustomOption="auto"
|
showCustomOption="auto"
|
||||||
isOptionEqualToValue={() => false}
|
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
const inputValueLower = inputValue.toLowerCase();
|
const inputValueLower = inputValue.toLowerCase();
|
||||||
const matched = [];
|
const matched = [];
|
||||||
@@ -383,12 +394,13 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
form.setValue('majorVersion', value);
|
form.setValue('majorVersion', value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
clearOnBlur
|
||||||
fullWidth
|
fullWidth
|
||||||
className="lg:col-span-1"
|
className="lg:col-span-1"
|
||||||
label="MAJOR"
|
label="MAJOR"
|
||||||
options={availableMajorVersions}
|
options={availableMajorVersions}
|
||||||
error={!!formState.errors?.majorVersion?.value?.message}
|
error={!!formState.errors?.majorVersion?.message}
|
||||||
helperText={formState.errors?.majorVersion?.value?.message}
|
helperText={formState.errors?.majorVersion?.message}
|
||||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||||
/>
|
/>
|
||||||
<ControlledAutocomplete
|
<ControlledAutocomplete
|
||||||
@@ -424,12 +436,13 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}}
|
}}
|
||||||
|
clearOnBlur
|
||||||
fullWidth
|
fullWidth
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
label="MINOR"
|
label="MINOR"
|
||||||
options={availableMinorVersions}
|
options={availableMinorVersions}
|
||||||
error={!!formState.errors?.minorVersion?.value?.message}
|
error={!!formState.errors?.minorVersion?.message}
|
||||||
helperText={formState.errors?.minorVersion?.value?.message}
|
helperText={formState.errors?.minorVersion?.message}
|
||||||
showCustomOption="auto"
|
showCustomOption="auto"
|
||||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useUI } from '@/components/common/UIProvider';
|
|||||||
import { Form } from '@/components/form/Form';
|
import { Form } from '@/components/form/Form';
|
||||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
import { Alert } from '@/components/ui/v2/Alert';
|
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
|
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||||
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
||||||
|
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { DatabaseStorageCapacityWarning } from '@/features/orgs/projects/database/settings/components/DatabaseStorageCapacityWarning';
|
||||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
@@ -15,18 +17,25 @@ import {
|
|||||||
useGetPostgresSettingsQuery,
|
useGetPostgresSettingsQuery,
|
||||||
useUpdateConfigMutation,
|
useUpdateConfigMutation,
|
||||||
} from '@/generated/graphql';
|
} from '@/generated/graphql';
|
||||||
|
import { ApplicationStatus } from '@/types/application';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const validationSchema = Yup.object({
|
const validationSchema = Yup.object({
|
||||||
capacity: Yup.number().required().min(10),
|
capacity: Yup.number()
|
||||||
|
.integer('Capacity must be an integer')
|
||||||
|
.typeError('You must specify a number')
|
||||||
|
.min(1, 'Capacity must be greater than 0')
|
||||||
|
.required('Capacity is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
export type DatabaseStorageCapacityFormValues = Yup.InferType<
|
||||||
|
typeof validationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export default function AuthDomain() {
|
export default function DatabaseStorageCapacity() {
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { org } = useCurrentOrg();
|
const { org } = useCurrentOrg();
|
||||||
const { maintenanceActive } = useUI();
|
const { maintenanceActive } = useUI();
|
||||||
@@ -58,8 +67,32 @@ export default function AuthDomain() {
|
|||||||
resolver: yupResolver(validationSchema),
|
resolver: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { formState, register, reset } = form;
|
const { state } = useAppState();
|
||||||
|
|
||||||
|
const applicationPause =
|
||||||
|
state === ApplicationStatus.Paused || state === ApplicationStatus.Pausing;
|
||||||
|
|
||||||
|
const { formState, register, reset, watch } = form;
|
||||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||||
|
const newCapacity = watch('capacity');
|
||||||
|
|
||||||
|
const decreasingSize = newCapacity < capacity;
|
||||||
|
|
||||||
|
const submitDisabled = useMemo(() => {
|
||||||
|
if (!isDirty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintenanceActive) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decreasingSize && !applicationPause) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [isDirty, maintenanceActive, decreasingSize, applicationPause]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && !loading) {
|
if (data && !loading) {
|
||||||
@@ -81,7 +114,7 @@ export default function AuthDomain() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
async function handleSubmit(formValues: DatabaseStorageCapacityFormValues) {
|
||||||
await execPromiseWithErrorToast(
|
await execPromiseWithErrorToast(
|
||||||
async () => {
|
async () => {
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
@@ -120,7 +153,7 @@ export default function AuthDomain() {
|
|||||||
description="Specify the storage capacity for your PostgreSQL database."
|
description="Specify the storage capacity for your PostgreSQL database."
|
||||||
slotProps={{
|
slotProps={{
|
||||||
submitButton: {
|
submitButton: {
|
||||||
disabled: !isDirty || maintenanceActive,
|
disabled: submitDisabled,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -134,25 +167,25 @@ export default function AuthDomain() {
|
|||||||
{...register('capacity')}
|
{...register('capacity')}
|
||||||
id="capacity"
|
id="capacity"
|
||||||
name="capacity"
|
name="capacity"
|
||||||
type="number"
|
type="text"
|
||||||
|
endAdornment={
|
||||||
|
<InputAdornment className="absolute right-2" position="end">
|
||||||
|
GB
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={project.legacyPlan?.isFree}
|
disabled={project.legacyPlan?.isFree}
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
error={Boolean(formState.errors.capacity?.message)}
|
error={Boolean(formState.errors.capacity?.message)}
|
||||||
helperText={formState.errors.capacity?.message}
|
helperText={formState.errors.capacity?.message}
|
||||||
slotProps={{
|
|
||||||
inputRoot: {
|
|
||||||
min: capacity,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{!project.legacyPlan?.isFree && (
|
{!project.legacyPlan?.isFree && (
|
||||||
<Alert severity="info" className="col-span-6 text-left">
|
<DatabaseStorageCapacityWarning
|
||||||
Note that volumes can only be increased (not decreased). Also, due
|
state={state}
|
||||||
to an AWS limitation, the same volume can only be increased once
|
decreasingSize={decreasingSize}
|
||||||
every 6 hours.
|
isDirty={isDirty}
|
||||||
</Alert>
|
/>
|
||||||
)}
|
)}
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Alert } from '@/components/ui/v2/Alert';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { ApplicationStatus } from '@/types/application';
|
||||||
|
|
||||||
|
interface DatabaseStorageCapacityWarningProps {
|
||||||
|
state: ApplicationStatus;
|
||||||
|
decreasingSize: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatabaseStorageCapacityWarning({
|
||||||
|
state,
|
||||||
|
decreasingSize,
|
||||||
|
isDirty,
|
||||||
|
}: DatabaseStorageCapacityWarningProps) {
|
||||||
|
const applicationPause =
|
||||||
|
state === ApplicationStatus.Paused || state === ApplicationStatus.Pausing;
|
||||||
|
|
||||||
|
if (!isDirty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === ApplicationStatus.Live && !decreasingSize) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||||
|
<Text className="flex items-start gap-1 font-semibold">
|
||||||
|
<span>⚠</span> Warning: Increasing disk size
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
Due to AWS limitations, disk size can only be modified once every 6
|
||||||
|
hours. Please ensure you increase capacity sufficiently to cover
|
||||||
|
your needs during this period.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === ApplicationStatus.Live && decreasingSize) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||||
|
<Text className="flex items-start gap-1 font-semibold">
|
||||||
|
<span>⚠</span> Warning: Decreasing disk size requires project to be
|
||||||
|
paused first.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (applicationPause && decreasingSize) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||||
|
<Text className="flex items-start gap-1 font-semibold">
|
||||||
|
<span>⚠</span> Warning: Ensure enough space before downsizing.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
Before downsizing, ensure enough space for your database, WAL files,
|
||||||
|
and other supporting data to prevent issues when unpausing your
|
||||||
|
project.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as DatabaseStorageCapacityWarning } from './DatabaseStorageCapacityWarning';
|
||||||
@@ -23,30 +23,17 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render the avatar of the user who deployed the application', () => {
|
test('should render the avatar of the user who deployed the application', () => {
|
||||||
render(
|
render(<DeploymentStatusMessage deployment={defaultDeployment} />);
|
||||||
<DeploymentStatusMessage
|
|
||||||
deployment={defaultDeployment}
|
|
||||||
appCreatedAt="2023-02-24"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole('img', {
|
screen.getByRole('img', {
|
||||||
name: `Avatar of ${defaultDeployment.commitUserName}`,
|
name: `Avatar of ${defaultDeployment.commitUserName}`,
|
||||||
}),
|
}),
|
||||||
).toHaveAttribute(
|
).toHaveAttribute('src', `${defaultDeployment.commitUserAvatarUrl}`);
|
||||||
'style',
|
|
||||||
`background-image: url(${defaultDeployment.commitUserAvatarUrl});`,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render "updated just now" when the deployment is in progress and has not ended', () => {
|
test('should render "updated just now" when the deployment is in progress and has not ended', () => {
|
||||||
render(
|
render(<DeploymentStatusMessage deployment={defaultDeployment} />);
|
||||||
<DeploymentStatusMessage
|
|
||||||
deployment={defaultDeployment}
|
|
||||||
appCreatedAt="2023-02-24"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
|
expect(screen.getByText(/updated just now/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -59,7 +46,6 @@ test('should render "updated just now" when the deployment\'s status is DEPLOYED
|
|||||||
deploymentStatus: 'DEPLOYED',
|
deploymentStatus: 'DEPLOYED',
|
||||||
deploymentEndedAt: null,
|
deploymentEndedAt: null,
|
||||||
}}
|
}}
|
||||||
appCreatedAt="2023-02-24"
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -76,19 +62,8 @@ test('should render "deployed 1 day ago" when the deployment has ended', () => {
|
|||||||
deploymentStatus: 'DEPLOYED',
|
deploymentStatus: 'DEPLOYED',
|
||||||
deploymentEndedAt: '2023-02-24T12:15:00.000Z',
|
deploymentEndedAt: '2023-02-24T12:15:00.000Z',
|
||||||
}}
|
}}
|
||||||
appCreatedAt="2023-02-24"
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/deployed 1 day ago/i)).toBeInTheDocument();
|
expect(screen.getByText(/deployed 1 day ago/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render "created 1 day ago" if the application does not have a deployment', () => {
|
|
||||||
vi.setSystemTime(new Date('2023-02-25T12:25:00.000Z'));
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DeploymentStatusMessage deployment={null} appCreatedAt="2023-02-24" />,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/created 1 day ago/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import { Avatar } from '@/components/ui/v1/Avatar';
|
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
import type { Deployment } from '@/types/application';
|
import type { Deployment } from '@/types/application';
|
||||||
import formatDistance from 'date-fns/formatDistance';
|
import formatDistance from 'date-fns/formatDistance';
|
||||||
|
|
||||||
export interface DeploymentStatusMessageProps {
|
export interface DeploymentStatusMessageProps {
|
||||||
/**
|
|
||||||
* The deployment to render the status message for.
|
|
||||||
*/
|
|
||||||
deployment: Partial<Deployment>;
|
deployment: Partial<Deployment>;
|
||||||
/**
|
|
||||||
* The date the application was created.
|
|
||||||
*/
|
|
||||||
appCreatedAt: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DeploymentStatusMessage({
|
export default function DeploymentStatusMessage({
|
||||||
deployment,
|
deployment,
|
||||||
appCreatedAt,
|
|
||||||
}: DeploymentStatusMessageProps) {
|
}: DeploymentStatusMessageProps) {
|
||||||
const isDeployingToProduction = [
|
const isDeployingToProduction = [
|
||||||
'SCHEDULED',
|
'SCHEDULED',
|
||||||
@@ -29,11 +21,10 @@ export default function DeploymentStatusMessage({
|
|||||||
(deployment && !deployment.deploymentEndedAt)
|
(deployment && !deployment.deploymentEndedAt)
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-row">
|
<span className="flex flex-row justify-start">
|
||||||
<Avatar
|
<Avatar
|
||||||
component="span"
|
alt={`Avatar of ${deployment.commitUserName}`}
|
||||||
name={deployment.commitUserName}
|
src={deployment.commitUserAvatarUrl}
|
||||||
avatarUrl={deployment.commitUserAvatarUrl}
|
|
||||||
className="mr-1 h-4 w-4 self-center"
|
className="mr-1 h-4 w-4 self-center"
|
||||||
/>
|
/>
|
||||||
<Text component="span" className="self-center text-sm">
|
<Text component="span" className="self-center text-sm">
|
||||||
@@ -44,30 +35,26 @@ export default function DeploymentStatusMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!isDeployingToProduction && deployment?.deploymentEndedAt) {
|
if (!isDeployingToProduction && deployment?.deploymentEndedAt) {
|
||||||
|
const statusMessage = `deployed ${formatDistance(new Date(deployment.deploymentEndedAt), new Date(), { addSuffix: true })}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="grid grid-flow-col">
|
<div className="relative flex flex-row">
|
||||||
<Avatar
|
<Avatar
|
||||||
component="span"
|
alt={`Avatar of ${deployment.commitUserName}`}
|
||||||
name={deployment.commitUserName}
|
src={deployment.commitUserAvatarUrl}
|
||||||
avatarUrl={deployment.commitUserAvatarUrl}
|
className="mr-2 mt-1 h-4 w-4"
|
||||||
className="mr-1 h-4 w-4 self-center"
|
|
||||||
/>
|
/>
|
||||||
<Text component="span" className="self-center truncate text-sm">
|
<div className="flex flex-col text-sm text-muted-foreground">
|
||||||
{deployment.commitUserName} deployed{' '}
|
<p className="line-clamp-1 break-all">{deployment.commitUserName}</p>
|
||||||
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
|
<p>{statusMessage}</p>
|
||||||
addSuffix: true,
|
</div>
|
||||||
})}
|
</div>
|
||||||
</Text>
|
|
||||||
</span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text component="span" className="text-sm">
|
<Text component="span" className="text-sm text-muted-foreground">
|
||||||
created{' '}
|
No deployments
|
||||||
{formatDistance(new Date(appCreatedAt), new Date(), {
|
|
||||||
addSuffix: true,
|
|
||||||
})}
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function useGetAppUsers({
|
|||||||
offset = 0,
|
offset = 0,
|
||||||
options = {},
|
options = {},
|
||||||
}: UseFilesOptions) {
|
}: UseFilesOptions) {
|
||||||
const { project } = useProject({ target: 'user-project' });
|
const { project } = useProject();
|
||||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||||
const { data, error, loading } = useRemoteAppGetUsersCustomQuery({
|
const { data, error, loading } = useRemoteAppGetUsersCustomQuery({
|
||||||
...options,
|
...options,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function useAppClient(
|
|||||||
options?: UseAppClientOptions,
|
options?: UseAppClientOptions,
|
||||||
): UseAppClientReturn {
|
): UseAppClientReturn {
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { project } = useProject({ target: 'user-project' });
|
const { project } = useProject();
|
||||||
|
|
||||||
if (!isPlatform) {
|
if (!isPlatform) {
|
||||||
return new NhostClient({
|
return new NhostClient({
|
||||||
|
|||||||
@@ -2,21 +2,16 @@ import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
|||||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||||
import {
|
import {
|
||||||
GetProjectDocument,
|
GetProjectDocument,
|
||||||
useGetProjectQuery,
|
|
||||||
type GetProjectQuery,
|
type GetProjectQuery,
|
||||||
type ProjectFragment,
|
type ProjectFragment,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
type Project = GetProjectQuery['apps'][0];
|
type Project = GetProjectQuery['apps'][0];
|
||||||
|
|
||||||
interface UseProjectOptions {
|
|
||||||
poll?: boolean;
|
|
||||||
target?: 'console-next' | 'user-project';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseProjectReturnType {
|
export interface UseProjectReturnType {
|
||||||
project: Project;
|
project: Project;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -24,10 +19,7 @@ export interface UseProjectReturnType {
|
|||||||
refetch: (variables?: any) => Promise<any>;
|
refetch: (variables?: any) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useProject({
|
export default function useProject(): UseProjectReturnType {
|
||||||
poll = false,
|
|
||||||
target = 'console-next',
|
|
||||||
}: UseProjectOptions = {}): UseProjectReturnType {
|
|
||||||
const {
|
const {
|
||||||
query: { appSubdomain },
|
query: { appSubdomain },
|
||||||
isReady: isRouterReady,
|
isReady: isRouterReady,
|
||||||
@@ -37,65 +29,36 @@ export default function useProject({
|
|||||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||||
useAuthenticationStatus();
|
useAuthenticationStatus();
|
||||||
|
|
||||||
const shouldFetchProject =
|
const shouldFetchProject = useMemo(
|
||||||
isPlatform &&
|
|
||||||
isAuthenticated &&
|
|
||||||
!isAuthLoading &&
|
|
||||||
!!appSubdomain &&
|
|
||||||
isRouterReady;
|
|
||||||
|
|
||||||
// Fetch project data for 'console-next' target
|
|
||||||
const {
|
|
||||||
data: consoleData,
|
|
||||||
loading: consoleLoading,
|
|
||||||
error: consoleError,
|
|
||||||
refetch: refetchConsole,
|
|
||||||
} = useGetProjectQuery({
|
|
||||||
variables: { subdomain: appSubdomain as string },
|
|
||||||
skip: !shouldFetchProject && target === 'console-next',
|
|
||||||
fetchPolicy: 'cache-and-network',
|
|
||||||
pollInterval: poll ? 5000 * 2 : 0, // every 10s
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch project data for 'user-project' target using client.graphql
|
|
||||||
const {
|
|
||||||
data: userProjectData,
|
|
||||||
isFetching: userProjectFetching,
|
|
||||||
refetch: refetchUserProject,
|
|
||||||
} = useQuery(
|
|
||||||
['currentProject', appSubdomain],
|
|
||||||
() =>
|
() =>
|
||||||
client.graphql.request<{ apps: ProjectFragment[] }>(GetProjectDocument, {
|
isPlatform &&
|
||||||
|
isAuthenticated &&
|
||||||
|
!isAuthLoading &&
|
||||||
|
!!appSubdomain &&
|
||||||
|
isRouterReady,
|
||||||
|
[isPlatform, isAuthenticated, isAuthLoading, appSubdomain, isRouterReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, error } = useQuery(
|
||||||
|
['project', appSubdomain as string],
|
||||||
|
async () => {
|
||||||
|
const response = await client.graphql.request<{
|
||||||
|
apps: ProjectFragment[];
|
||||||
|
}>(GetProjectDocument, {
|
||||||
subdomain: (appSubdomain as string) || '',
|
subdomain: (appSubdomain as string) || '',
|
||||||
}),
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
{
|
{
|
||||||
keepPreviousData: true,
|
enabled: shouldFetchProject,
|
||||||
enabled: shouldFetchProject && target === 'user-project',
|
|
||||||
staleTime: poll ? 5000 : Infinity, // Adjust staleTime for better performance
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const project =
|
|
||||||
target === 'console-next'
|
|
||||||
? consoleData?.apps?.[0] || null
|
|
||||||
: userProjectData?.data?.apps?.[0] || null;
|
|
||||||
|
|
||||||
const loading =
|
|
||||||
target === 'console-next'
|
|
||||||
? consoleLoading || isAuthLoading
|
|
||||||
: userProjectFetching || isAuthLoading;
|
|
||||||
const error = consoleError
|
|
||||||
? new Error(consoleError.message || 'Unknown error occurred.')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
const refetch =
|
|
||||||
target === 'console-next' ? refetchConsole : refetchUserProject;
|
|
||||||
|
|
||||||
if (isPlatform) {
|
if (isPlatform) {
|
||||||
return {
|
return {
|
||||||
project,
|
project: data?.data?.apps?.[0] || null,
|
||||||
loading,
|
loading: isLoading && shouldFetchProject,
|
||||||
error,
|
error: Array.isArray(error || {}) ? error[0] : error,
|
||||||
refetch,
|
refetch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useProjectWithState } from './useProjectWithState';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||||
|
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||||
|
import {
|
||||||
|
GetProjectStateDocument,
|
||||||
|
type GetProjectQuery,
|
||||||
|
type ProjectFragment,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
type Project = GetProjectQuery['apps'][0];
|
||||||
|
|
||||||
|
export interface UseProjectWithStateReturnType {
|
||||||
|
project: Project;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: Error;
|
||||||
|
refetch: (variables?: any) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useProjectWithState(): UseProjectWithStateReturnType {
|
||||||
|
const {
|
||||||
|
query: { appSubdomain },
|
||||||
|
isReady: isRouterReady,
|
||||||
|
} = useRouter();
|
||||||
|
const client = useNhostClient();
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||||
|
useAuthenticationStatus();
|
||||||
|
|
||||||
|
const shouldFetchProject = useMemo(
|
||||||
|
() =>
|
||||||
|
isPlatform &&
|
||||||
|
isAuthenticated &&
|
||||||
|
!isAuthLoading &&
|
||||||
|
!!appSubdomain &&
|
||||||
|
isRouterReady,
|
||||||
|
[isPlatform, isAuthenticated, isAuthLoading, appSubdomain, isRouterReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, error } = useQuery(
|
||||||
|
['projectWithState', appSubdomain as string],
|
||||||
|
async () => {
|
||||||
|
const response = await client.graphql.request<{
|
||||||
|
apps: ProjectFragment[];
|
||||||
|
}>(GetProjectStateDocument, {
|
||||||
|
subdomain: (appSubdomain as string) || '',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: shouldFetchProject,
|
||||||
|
keepPreviousData: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchInterval: 10000, // poll every 10s
|
||||||
|
staleTime: 1000 * 60 * 5, // 1 minutes
|
||||||
|
cacheTime: 1000 * 60 * 6, //
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPlatform) {
|
||||||
|
return {
|
||||||
|
project: data?.data?.apps?.[0] || null,
|
||||||
|
loading: isLoading && shouldFetchProject,
|
||||||
|
error: Array.isArray(error || {}) ? error[0] : error,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: localApplication,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ const smtpValidationSchema = yup
|
|||||||
.required(),
|
.required(),
|
||||||
user: yup.string().label('Username').required(),
|
user: yup.string().label('Username').required(),
|
||||||
password: yup.string().label('Password'),
|
password: yup.string().label('Password'),
|
||||||
sender: yup.string().label('SMTP Sender').email().required(),
|
sender: yup.string().label('SMTP Sender').required(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -1,54 +1,132 @@
|
|||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import type { MetricsCardProps } from '@/features/orgs/projects/overview/components/MetricsCard';
|
import type { MetricsCardProps } from '@/features/orgs/projects/overview/components/MetricsCard';
|
||||||
import { MetricsCard } from '@/features/orgs/projects/overview/components/MetricsCard';
|
import { MetricsCard } from '@/features/orgs/projects/overview/components/MetricsCard';
|
||||||
import { prettifyNumber } from '@/utils/prettifyNumber';
|
import { prettifyNumber } from '@/utils/prettifyNumber';
|
||||||
import { prettifySize } from '@/utils/prettifySize';
|
import {
|
||||||
import { useGetProjectMetricsQuery } from '@/utils/__generated__/graphql';
|
useGetProjectMetricsQuery,
|
||||||
|
useGetProjectRequestsMetricQuery,
|
||||||
|
useGetUserProjectMetricsQuery,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
import { prettifySize } from '@/utils/prettifySize';
|
||||||
|
import { formatISO, startOfDay, startOfMonth, subMinutes } from 'date-fns';
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
export default function OverviewMetrics() {
|
export default function OverviewMetrics() {
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
const { data, loading, error } = useGetProjectMetricsQuery({
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
allUsers: { aggregate: { count: allUsers = 0 } = {} } = {},
|
||||||
|
dailyActiveUsers: {
|
||||||
|
aggregate: { count: dailyActiveUsers = 0 } = {},
|
||||||
|
} = {},
|
||||||
|
monthlyActiveUsers: {
|
||||||
|
aggregate: { count: monthlyActiveUsers = 0 } = {},
|
||||||
|
} = {},
|
||||||
|
filesAggregate: {
|
||||||
|
aggregate: { sum: { size: totalStorage = 0 } = {} } = {},
|
||||||
|
} = {},
|
||||||
|
} = {},
|
||||||
|
} = useGetUserProjectMetricsQuery({
|
||||||
|
client: remoteProjectGQLClient,
|
||||||
variables: {
|
variables: {
|
||||||
appId: project?.id,
|
startOfMonth: startOfMonth(new Date()),
|
||||||
|
today: startOfDay(new Date()),
|
||||||
|
},
|
||||||
|
skip: !project,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
totalRequests: { value: totalRequestsInLastFiveMinutes = 0 } = {},
|
||||||
|
} = {},
|
||||||
|
} = useGetProjectRequestsMetricQuery({
|
||||||
|
variables: {
|
||||||
|
appId: project.id,
|
||||||
|
from: formatISO(subMinutes(new Date(), 6)), // 6 mns earlier
|
||||||
|
to: formatISO(subMinutes(new Date(), 1)), // 1 mn earlier
|
||||||
|
},
|
||||||
|
skip: !project,
|
||||||
|
pollInterval: 1000 * 60 * 5, // Poll every 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: {
|
||||||
|
functionsDuration: { value: functionsDuration = 0 } = {},
|
||||||
|
totalRequests: { value: totalRequests = 0 } = {},
|
||||||
|
postgresVolumeUsage: { value: postgresVolumeUsage = 0 } = {},
|
||||||
|
egressVolume: { value: egressVolume = 0 } = {},
|
||||||
|
} = {},
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
} = useGetProjectMetricsQuery({
|
||||||
|
variables: {
|
||||||
|
appId: project.id,
|
||||||
subdomain: project?.subdomain,
|
subdomain: project?.subdomain,
|
||||||
from: new Date(now.getFullYear(), now.getMonth(), 1),
|
from: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||||
},
|
},
|
||||||
skip: !project?.id,
|
skip: !project,
|
||||||
});
|
});
|
||||||
|
|
||||||
const cardElements: MetricsCardProps[] = [
|
const cardElements: MetricsCardProps[] = [
|
||||||
{
|
{
|
||||||
label: 'CPU Usage Seconds',
|
label: 'Daily Active Users',
|
||||||
tooltip: 'Total time the service has used the CPUs',
|
tooltip: 'Unique users active today',
|
||||||
value: prettifyNumber(data?.cpuSecondsUsage?.value || 0),
|
value: prettifyNumber(dailyActiveUsers),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Monthly Active Users',
|
||||||
|
tooltip: 'Unique users active this month',
|
||||||
|
value: prettifyNumber(monthlyActiveUsers),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'All Users',
|
||||||
|
tooltip: 'Total registered users',
|
||||||
|
value: prettifyNumber(allUsers),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'RPS',
|
||||||
|
tooltip: 'Requests Per Second (RPS) measured in the last 5 minutes',
|
||||||
|
value: prettifyNumber(totalRequestsInLastFiveMinutes / 300, {
|
||||||
|
numberOfDecimals: 2,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Total Requests',
|
label: 'Total Requests',
|
||||||
tooltip:
|
tooltip: 'Total service requests this month so far (excluding functions)',
|
||||||
'Total amount of requests your services have received excluding functions',
|
value: prettifyNumber(totalRequests || 0, {
|
||||||
value: prettifyNumber(data?.totalRequests?.value || 0, {
|
numberOfDecimals: totalRequests > 1000 ? 2 : 0,
|
||||||
numberOfDecimals: data?.totalRequests?.value > 1000 ? 2 : 0,
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Function Invocations',
|
label: 'Egress',
|
||||||
tooltip: 'Number of times your functions have been called',
|
tooltip: 'Total outgoing data transfer this month so far',
|
||||||
value: prettifyNumber(data?.functionInvocations?.value || 0, {
|
value: prettifySize(egressVolume),
|
||||||
numberOfDecimals: 0,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Logs',
|
label: 'Functions Duration',
|
||||||
tooltip: 'Amount of logs stored',
|
tooltip: 'Total Functions execution this month so far',
|
||||||
value: prettifySize(data?.logsVolume?.value || 0),
|
value: prettifyNumber(functionsDuration),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Storage',
|
||||||
|
tooltip: 'Total size of stored files in the storage service',
|
||||||
|
value: prettifySize(totalStorage || 0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Postgres Volume Usage',
|
||||||
|
tooltip: 'Used storage in the Postgres database',
|
||||||
|
value: prettifySize(postgresVolumeUsage),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (!data && error) {
|
if (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,19 @@ import { calculateBillableResources } from '@/features/orgs/projects/resources/s
|
|||||||
import type { ResourceSettingsFormValues } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
import type { ResourceSettingsFormValues } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||||
import { resourceSettingsValidationSchema } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
import { resourceSettingsValidationSchema } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import {
|
import type {
|
||||||
RESOURCE_VCPU_MULTIPLIER,
|
ConfigConfigUpdateInput,
|
||||||
RESOURCE_VCPU_PRICE,
|
GetResourcesQuery,
|
||||||
} from '@/utils/constants/common';
|
} from '@/utils/__generated__/graphql';
|
||||||
import type { GetResourcesQuery } from '@/utils/__generated__/graphql';
|
|
||||||
import {
|
import {
|
||||||
useGetResourcesQuery,
|
useGetResourcesQuery,
|
||||||
useUpdateConfigMutation,
|
useUpdateConfigMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import {
|
||||||
|
RESOURCE_VCPU_MULTIPLIER,
|
||||||
|
RESOURCE_VCPU_PRICE,
|
||||||
|
} from '@/utils/constants/common';
|
||||||
|
import { removeTypename } from '@/utils/helpers';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
@@ -36,7 +40,7 @@ function getInitialServiceResources(
|
|||||||
data: GetResourcesQuery,
|
data: GetResourcesQuery,
|
||||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||||
) {
|
) {
|
||||||
const { compute, replicas, autoscaler } =
|
const { compute, replicas, autoscaler, ...rest } =
|
||||||
data?.config?.[service]?.resources || {};
|
data?.config?.[service]?.resources || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -44,6 +48,7 @@ function getInitialServiceResources(
|
|||||||
vcpu: compute?.cpu || 0,
|
vcpu: compute?.cpu || 0,
|
||||||
memory: compute?.memory || 0,
|
memory: compute?.memory || 0,
|
||||||
autoscale: autoscaler || null,
|
autoscale: autoscaler || null,
|
||||||
|
rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,76 +181,130 @@ export default function ResourcesForm() {
|
|||||||
? (billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE
|
? (billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const getFormattedConfig = (
|
||||||
|
values: ResourceSettingsFormValues,
|
||||||
|
): ConfigConfigUpdateInput => {
|
||||||
|
const sanitizedValues = removeTypename(
|
||||||
|
values,
|
||||||
|
) as ResourceSettingsFormValues;
|
||||||
|
|
||||||
|
const sanitizedInitialDatabaseResources = removeTypename(
|
||||||
|
initialDatabaseResources,
|
||||||
|
);
|
||||||
|
const sanitizedInitialHasuraResources = removeTypename(
|
||||||
|
initialHasuraResources,
|
||||||
|
);
|
||||||
|
const sanitizedInitialAuthResources = removeTypename(initialAuthResources);
|
||||||
|
const sanitizedInitialStorageResources = removeTypename(
|
||||||
|
initialStorageResources,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sanitizedValues.enabled) {
|
||||||
|
return {
|
||||||
|
postgres: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.database.vcpu,
|
||||||
|
memory: sanitizedValues.database.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.database.replicas,
|
||||||
|
autoscaler: sanitizedValues.database.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.database.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialDatabaseResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasura: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.hasura.vcpu,
|
||||||
|
memory: sanitizedValues.hasura.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.hasura.replicas,
|
||||||
|
autoscaler: sanitizedValues.hasura.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.hasura.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialHasuraResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.auth.vcpu,
|
||||||
|
memory: sanitizedValues.auth.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.auth.replicas,
|
||||||
|
autoscaler: sanitizedValues.auth.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.auth.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialAuthResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.storage.vcpu,
|
||||||
|
memory: sanitizedValues.storage.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.storage.replicas,
|
||||||
|
autoscaler: sanitizedValues.storage.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.storage.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialStorageResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
postgres: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialDatabaseResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasura: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialHasuraResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialAuthResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialStorageResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||||
const updateConfigPromise = updateConfig({
|
const updateConfigPromise = updateConfig({
|
||||||
variables: {
|
variables: {
|
||||||
appId: project?.id,
|
appId: project?.id,
|
||||||
config: {
|
config: getFormattedConfig(formValues),
|
||||||
postgres: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.database.vcpu,
|
|
||||||
memory: formValues.database.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.database.replicas,
|
|
||||||
autoscaler: formValues.database.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.database.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
hasura: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.hasura.vcpu,
|
|
||||||
memory: formValues.hasura.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.hasura.replicas,
|
|
||||||
autoscaler: formValues.hasura.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.hasura.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.auth.vcpu,
|
|
||||||
memory: formValues.auth.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.auth.replicas,
|
|
||||||
autoscaler: formValues.auth.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.auth.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.storage.vcpu,
|
|
||||||
memory: formValues.storage.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.storage.replicas,
|
|
||||||
autoscaler: formValues.storage.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.storage.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="grid grid-flow-row gap-2">
|
<Box className="grid grid-flow-row gap-2">
|
||||||
<Box className="grid items-center justify-between grid-flow-col gap-2">
|
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||||
<Text>
|
<Text>
|
||||||
Allocated vCPUs:{' '}
|
Allocated vCPUs:{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
@@ -201,7 +201,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="grid grid-flow-row gap-2">
|
<Box className="grid grid-flow-row gap-2">
|
||||||
<Box className="grid items-center justify-between grid-flow-col gap-2">
|
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||||
<Text>
|
<Text>
|
||||||
Allocated Memory:{' '}
|
Allocated Memory:{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
@@ -246,7 +246,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
>
|
>
|
||||||
<ExclamationIcon
|
<ExclamationIcon
|
||||||
color="error"
|
color="error"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -274,7 +274,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
>
|
>
|
||||||
<ExclamationIcon
|
<ExclamationIcon
|
||||||
color="error"
|
color="error"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -306,7 +306,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
title={`Enable autoscaler to automatically provision extra ${title} replicas when needed.`}
|
title={`Enable autoscaler to automatically provision extra ${title} replicas when needed.`}
|
||||||
>
|
>
|
||||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
<InfoOutlinedIcon className="h-4 w-4" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -323,7 +323,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
className="font-medium"
|
className="font-medium"
|
||||||
>
|
>
|
||||||
Service Replicas
|
Service Replicas
|
||||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ fragment ServiceResources on ConfigConfig {
|
|||||||
autoscaler {
|
autoscaler {
|
||||||
maxReplicas
|
maxReplicas
|
||||||
}
|
}
|
||||||
|
networking {
|
||||||
|
ingresses {
|
||||||
|
fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasura {
|
hasura {
|
||||||
@@ -21,10 +26,19 @@ fragment ServiceResources on ConfigConfig {
|
|||||||
autoscaler {
|
autoscaler {
|
||||||
maxReplicas
|
maxReplicas
|
||||||
}
|
}
|
||||||
|
networking {
|
||||||
|
ingresses {
|
||||||
|
fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
postgres {
|
postgres {
|
||||||
resources {
|
resources {
|
||||||
|
storage {
|
||||||
|
capacity
|
||||||
|
}
|
||||||
|
enablePublicAccess
|
||||||
compute {
|
compute {
|
||||||
cpu
|
cpu
|
||||||
memory
|
memory
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { StorageFormSection } from '@/features/orgs/projects/services/components
|
|||||||
import { useHostName } from '@/features/projects/common/hooks/useHostName';
|
import { useHostName } from '@/features/projects/common/hooks/useHostName';
|
||||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validationSchema,
|
validationSchema,
|
||||||
@@ -29,16 +28,15 @@ import {
|
|||||||
|
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||||
|
import {
|
||||||
|
useInsertRunServiceConfigMutation,
|
||||||
|
useReplaceRunServiceConfigMutation,
|
||||||
|
type ConfigRunServiceConfigInsertInput,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||||
import { copy } from '@/utils/copy';
|
import { copy } from '@/utils/copy';
|
||||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
import { removeTypename } from '@/utils/helpers';
|
import { removeTypename } from '@/utils/helpers';
|
||||||
import {
|
|
||||||
useInsertRunServiceConfigMutation,
|
|
||||||
useInsertRunServiceMutation,
|
|
||||||
useReplaceRunServiceConfigMutation,
|
|
||||||
type ConfigRunServiceConfigInsertInput,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
@@ -58,9 +56,10 @@ export default function ServiceForm({
|
|||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const localMimirClient = useLocalMimirClient();
|
const localMimirClient = useLocalMimirClient();
|
||||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||||
const [insertRunService] = useInsertRunServiceMutation();
|
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation({
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
|
});
|
||||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
@@ -96,14 +95,14 @@ export default function ServiceForm({
|
|||||||
if (serviceID) {
|
if (serviceID) {
|
||||||
return serviceID;
|
return serviceID;
|
||||||
}
|
}
|
||||||
return uuidv4();
|
return '<uuid-to-be-generated-on-creation>';
|
||||||
}, [serviceID]);
|
}, [serviceID]);
|
||||||
|
|
||||||
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
||||||
|
|
||||||
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
||||||
|
|
||||||
if (initialData?.image?.startsWith(privateRegistryImage)) {
|
if (initialData?.image?.startsWith(privateRegistryImage.split('/')[0])) {
|
||||||
initialImageType = 'nhost';
|
initialImageType = 'nhost';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,33 +224,14 @@ export default function ServiceForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert service config
|
// Create service
|
||||||
const {
|
|
||||||
data: {
|
|
||||||
insertRunService: { id },
|
|
||||||
},
|
|
||||||
} = await insertRunService({
|
|
||||||
variables: {
|
|
||||||
object: {
|
|
||||||
appID: project.id,
|
|
||||||
id: newServiceID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await insertRunServiceConfig({
|
await insertRunServiceConfig({
|
||||||
variables: {
|
variables: {
|
||||||
appID: project.id,
|
appID: project.id,
|
||||||
serviceID: id,
|
|
||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
image: {
|
image: {
|
||||||
// If the image field left empty then we auto-populate following this format
|
image: values.image,
|
||||||
// registry.<region>.<nhost_domain>/<service_id>
|
|
||||||
image:
|
|
||||||
values.image.length > 0
|
|
||||||
? values.image
|
|
||||||
: `registry.${project.region.name}.${project.region.domain}/${newServiceID}`,
|
|
||||||
pullCredentials:
|
pullCredentials:
|
||||||
values.pullCredentials?.length > 0
|
values.pullCredentials?.length > 0
|
||||||
? values.pullCredentials
|
? values.pullCredentials
|
||||||
@@ -335,7 +315,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Name of the service, must be unique per project.">
|
<Tooltip title="Name of the service, must be unique per project.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -359,7 +339,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -414,7 +394,7 @@ export default function ServiceForm({
|
|||||||
{createServiceFormError && (
|
{createServiceFormError && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="error"
|
severity="error"
|
||||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
<strong>Error:</strong> {createServiceFormError.message}
|
<strong>Error:</strong> {createServiceFormError.message}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
|
|
||||||
export const validationSchema = Yup.object({
|
export const validationSchema = Yup.object({
|
||||||
name: Yup.string().required('The name is required.'),
|
name: Yup.string().required('The name is required.'),
|
||||||
image: Yup.string().label('Image to run').required('The image is required.'),
|
image: Yup.string()
|
||||||
|
.trim()
|
||||||
|
.label('Image to run')
|
||||||
|
.required('The image is required.')
|
||||||
|
.min(1, 'Image must be at least 1 character long'),
|
||||||
pullCredentials: Yup.string().label('Pull credentials').nullable(),
|
pullCredentials: Yup.string().label('Pull credentials').nullable(),
|
||||||
command: Yup.string(),
|
command: Yup.string(),
|
||||||
environment: Yup.array().of(
|
environment: Yup.array().of(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ReplicasFormSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="p-4 space-y-4 rounded border-1">
|
<Box className="space-y-4 rounded border-1 p-4">
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
<Box className="flex flex-row items-center space-x-2">
|
||||||
<Text variant="h4" className="font-semibold">
|
<Text variant="h4" className="font-semibold">
|
||||||
Replicas ({replicas})
|
Replicas ({replicas})
|
||||||
@@ -65,7 +65,7 @@ export default function ReplicasFormSection() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export default function ReplicasFormSection() {
|
|||||||
/>
|
/>
|
||||||
<Text>Autoscaler</Text>
|
<Text>Autoscaler</Text>
|
||||||
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
||||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
<InfoOutlinedIcon className="h-4 w-4" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||||
|
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||||
|
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
export type DataGridDecimalCellProps<TData extends object> =
|
||||||
|
CommonDataGridCellProps<TData, number | string>;
|
||||||
|
|
||||||
|
export default function DataGridDecimalCell<TData extends object>({
|
||||||
|
onSave,
|
||||||
|
optimisticValue,
|
||||||
|
temporaryValue,
|
||||||
|
onTemporaryValueChange,
|
||||||
|
}: DataGridDecimalCellProps<TData>) {
|
||||||
|
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||||
|
useDataGridCell<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (onSave) {
|
||||||
|
if (typeof temporaryValue === 'string') {
|
||||||
|
await onSave(parseFloat(temporaryValue));
|
||||||
|
} else if (typeof temporaryValue === 'number') {
|
||||||
|
await onSave(temporaryValue);
|
||||||
|
} else {
|
||||||
|
await onSave(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowLeft' ||
|
||||||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'ArrowUp' ||
|
||||||
|
event.key === 'ArrowDown' ||
|
||||||
|
event.key === 'Backspace'
|
||||||
|
) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
await handleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
await handleSave();
|
||||||
|
await focusCell();
|
||||||
|
cancelEditCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (onTemporaryValueChange) {
|
||||||
|
onTemporaryValueChange(event.target.value ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
|
value={
|
||||||
|
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||||
|
? temporaryValue
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||||
|
sx={{
|
||||||
|
[`&.${inputClasses.focused}`]: {
|
||||||
|
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||||
|
borderColor: 'transparent !important',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark'
|
||||||
|
? `${theme.palette.secondary[100]} !important`
|
||||||
|
: `${theme.palette.common.white} !important`,
|
||||||
|
},
|
||||||
|
[`& .${inputClasses.input}`]: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
inputWrapper: { className: 'h-full' },
|
||||||
|
input: { className: 'h-full' },
|
||||||
|
inputRoot: {
|
||||||
|
className:
|
||||||
|
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optimisticValue === null || typeof optimisticValue === 'undefined') {
|
||||||
|
return (
|
||||||
|
<Text className="truncate !text-xs" color="disabled">
|
||||||
|
null
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text className="truncate !text-xs">{optimisticValue}</Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './DataGridDecimalCell';
|
||||||
|
export { default as DataGridDecimalCell } from './DataGridDecimalCell';
|
||||||
@@ -4,15 +4,15 @@ import { Input, inputClasses } from '@/components/ui/v2/Input';
|
|||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
export type DataGridNumericCellProps<TData extends object> =
|
export type DataGridIntegerCellProps<TData extends object> =
|
||||||
CommonDataGridCellProps<TData, number>;
|
CommonDataGridCellProps<TData, number>;
|
||||||
|
|
||||||
export default function DataGridNumericCell<TData extends object>({
|
export default function DataGridIntegerCell<TData extends object>({
|
||||||
onSave,
|
onSave,
|
||||||
optimisticValue,
|
optimisticValue,
|
||||||
temporaryValue,
|
temporaryValue,
|
||||||
onTemporaryValueChange,
|
onTemporaryValueChange,
|
||||||
}: DataGridNumericCellProps<TData>) {
|
}: DataGridIntegerCellProps<TData>) {
|
||||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||||
useDataGridCell<HTMLInputElement>();
|
useDataGridCell<HTMLInputElement>();
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './DataGridIntegerCell';
|
||||||
|
export { default as DataGridIntegerCell } from './DataGridIntegerCell';
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridNumericCell';
|
|
||||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
|
||||||
@@ -166,7 +166,7 @@ export default function DataGridPreviewCell<TData extends object>({
|
|||||||
value: { fetchBlob, id, mimeType, alt, blob },
|
value: { fetchBlob, id, mimeType, alt, blob },
|
||||||
fallbackPreview = null,
|
fallbackPreview = null,
|
||||||
}: DataGridPreviewCellProps<TData>) {
|
}: DataGridPreviewCellProps<TData>) {
|
||||||
const { project } = useProject({ target: 'user-project' });
|
const { project } = useProject();
|
||||||
const appClient = useAppClient();
|
const appClient = useAppClient();
|
||||||
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import { FilesDataGridControls } from '@/features/orgs/projects/storage/dataGrid
|
|||||||
import { useBuckets } from '@/features/orgs/projects/storage/dataGrid/hooks/useBuckets';
|
import { useBuckets } from '@/features/orgs/projects/storage/dataGrid/hooks/useBuckets';
|
||||||
import { useFiles } from '@/features/orgs/projects/storage/dataGrid/hooks/useFiles';
|
import { useFiles } from '@/features/orgs/projects/storage/dataGrid/hooks/useFiles';
|
||||||
import { useFilesAggregate } from '@/features/orgs/projects/storage/dataGrid/hooks/useFilesAggregate';
|
import { useFilesAggregate } from '@/features/orgs/projects/storage/dataGrid/hooks/useFilesAggregate';
|
||||||
import { getHasuraAdminSecret } from '@/utils/env';
|
|
||||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
|
||||||
import type { Files } from '@/utils/__generated__/graphql';
|
import type { Files } from '@/utils/__generated__/graphql';
|
||||||
import { Order_By as OrderBy } from '@/utils/__generated__/graphql';
|
import { Order_By as OrderBy } from '@/utils/__generated__/graphql';
|
||||||
|
import { getHasuraAdminSecret } from '@/utils/env';
|
||||||
|
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||||
import debounce from 'lodash.debounce';
|
import debounce from 'lodash.debounce';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import type { ChangeEvent } from 'react';
|
import type { ChangeEvent } from 'react';
|
||||||
@@ -32,7 +32,7 @@ export type FilesDataGridProps = Partial<DataGridProps<StoredFile>>;
|
|||||||
|
|
||||||
export default function FilesDataGrid(props: FilesDataGridProps) {
|
export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { project } = useProject({ target: 'user-project' });
|
const { project } = useProject();
|
||||||
const appClient = useAppClient();
|
const appClient = useAppClient();
|
||||||
const [searchString, setSearchString] = useState<string | null>(null);
|
const [searchString, setSearchString] = useState<string | null>(null);
|
||||||
const [currentOffset, setCurrentOffset] = useState<number | null>(
|
const [currentOffset, setCurrentOffset] = useState<number | null>(
|
||||||
@@ -118,7 +118,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
|||||||
DataGridPreviewCell({
|
DataGridPreviewCell({
|
||||||
...cellProps,
|
...cellProps,
|
||||||
fallbackPreview: (
|
fallbackPreview: (
|
||||||
<FilePreviewIcon className="w-5 h-5 fill-current" />
|
<FilePreviewIcon className="h-5 w-5 fill-current" />
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
minWidth: 80,
|
minWidth: 80,
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
|||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import type { FileUploadButtonProps } from '@/features/orgs/projects/storage/dataGrid/components/FileUploadButton';
|
import type { FileUploadButtonProps } from '@/features/orgs/projects/storage/dataGrid/components/FileUploadButton';
|
||||||
import { FileUploadButton } from '@/features/orgs/projects/storage/dataGrid/components/FileUploadButton';
|
import { FileUploadButton } from '@/features/orgs/projects/storage/dataGrid/components/FileUploadButton';
|
||||||
|
import type { Files } from '@/utils/__generated__/graphql';
|
||||||
import { getHasuraAdminSecret } from '@/utils/env';
|
import { getHasuraAdminSecret } from '@/utils/env';
|
||||||
import { triggerToast } from '@/utils/toast';
|
import { triggerToast } from '@/utils/toast';
|
||||||
import type { Files } from '@/utils/__generated__/graphql';
|
|
||||||
import type { PropsWithoutRef } from 'react';
|
import type { PropsWithoutRef } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { Row } from 'react-table';
|
import type { Row } from 'react-table';
|
||||||
@@ -38,7 +38,7 @@ export default function FilesDataGridControls({
|
|||||||
...props
|
...props
|
||||||
}: FilesDataGridControlsProps) {
|
}: FilesDataGridControlsProps) {
|
||||||
const { openAlertDialog } = useDialog();
|
const { openAlertDialog } = useDialog();
|
||||||
const { project } = useProject({ target: 'user-project' });
|
const { project } = useProject();
|
||||||
const appClient = useAppClient();
|
const appClient = useAppClient();
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
|
||||||
@@ -160,7 +160,7 @@ export default function FilesDataGridControls({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid w-full grid-cols-12 gap-2 mx-auto">
|
<div className="mx-auto grid w-full grid-cols-12 gap-2">
|
||||||
<Input
|
<Input
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'col-span-12 xs+:col-span-12 md:col-span-9 xl:col-span-10',
|
'col-span-12 xs+:col-span-12 md:col-span-9 xl:col-span-10',
|
||||||
@@ -170,7 +170,7 @@ export default function FilesDataGridControls({
|
|||||||
{...restFilterProps}
|
{...restFilterProps}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="grid grid-flow-col col-span-12 gap-2 md:col-span-3 xl:col-span-2">
|
<div className="col-span-12 grid grid-flow-col gap-2 md:col-span-3 xl:col-span-2">
|
||||||
<DataGridPagination
|
<DataGridPagination
|
||||||
className={twMerge('col-span-6', paginationClassName)}
|
className={twMerge('col-span-6', paginationClassName)}
|
||||||
{...restPaginationProps}
|
{...restPaginationProps}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { getHasuraAdminSecret } from '@/utils/env';
|
|
||||||
import type {
|
import type {
|
||||||
Files_Order_By as FilesOrderBy,
|
Files_Order_By as FilesOrderBy,
|
||||||
GetFilesQuery,
|
GetFilesQuery,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
import { useGetFilesQuery } from '@/utils/__generated__/graphql';
|
import { useGetFilesQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { getHasuraAdminSecret } from '@/utils/env';
|
||||||
import type { QueryHookOptions } from '@apollo/client';
|
import type { QueryHookOptions } from '@apollo/client';
|
||||||
|
|
||||||
export type UseFilesOptions = {
|
export type UseFilesOptions = {
|
||||||
@@ -38,7 +38,7 @@ export default function useFiles({
|
|||||||
orderBy,
|
orderBy,
|
||||||
options = {},
|
options = {},
|
||||||
}: UseFilesOptions) {
|
}: UseFilesOptions) {
|
||||||
const { project } = useProject({ target: 'user-project' });
|
const { project } = useProject();
|
||||||
const { data, previousData, ...rest } = useGetFilesQuery({
|
const { data, previousData, ...rest } = useGetFilesQuery({
|
||||||
variables: {
|
variables: {
|
||||||
where: searchString
|
where: searchString
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export default function useNotFoundRedirect() {
|
|||||||
router.pathname === '/account' ||
|
router.pathname === '/account' ||
|
||||||
router.pathname === '/support/ticket' ||
|
router.pathname === '/support/ticket' ||
|
||||||
router.pathname === '/run-one-click-install' ||
|
router.pathname === '/run-one-click-install' ||
|
||||||
|
router.pathname.includes('/orgs/_') ||
|
||||||
|
router.pathname.includes('/orgs/_/projects/_') ||
|
||||||
orgSlug ||
|
orgSlug ||
|
||||||
(orgSlug && appSubdomain) ||
|
(orgSlug && appSubdomain) ||
|
||||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||||
|
|||||||
@@ -28,16 +28,15 @@ import {
|
|||||||
type ServiceFormValues,
|
type ServiceFormValues,
|
||||||
} from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
} from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||||
|
import {
|
||||||
|
useInsertRunServiceConfigMutation,
|
||||||
|
useReplaceRunServiceConfigMutation,
|
||||||
|
type ConfigRunServiceConfigInsertInput,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||||
import { copy } from '@/utils/copy';
|
import { copy } from '@/utils/copy';
|
||||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
import { removeTypename } from '@/utils/helpers';
|
import { removeTypename } from '@/utils/helpers';
|
||||||
import {
|
|
||||||
useInsertRunServiceConfigMutation,
|
|
||||||
useInsertRunServiceMutation,
|
|
||||||
useReplaceRunServiceConfigMutation,
|
|
||||||
type ConfigRunServiceConfigInsertInput,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
@@ -57,7 +56,6 @@ export default function ServiceForm({
|
|||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const localMimirClient = useLocalMimirClient();
|
const localMimirClient = useLocalMimirClient();
|
||||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||||
const [insertRunService] = useInsertRunServiceMutation();
|
|
||||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||||
@@ -187,20 +185,11 @@ export default function ServiceForm({
|
|||||||
// Insert service config
|
// Insert service config
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
insertRunService: { id: newServiceID, subdomain },
|
insertRunServiceConfig: { serviceID: newServiceID },
|
||||||
},
|
},
|
||||||
} = await insertRunService({
|
} = await insertRunServiceConfig({
|
||||||
variables: {
|
|
||||||
object: {
|
|
||||||
appID: currentProject.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await insertRunServiceConfig({
|
|
||||||
variables: {
|
variables: {
|
||||||
appID: currentProject.id,
|
appID: currentProject.id,
|
||||||
serviceID: newServiceID,
|
|
||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
image: {
|
image: {
|
||||||
@@ -209,14 +198,14 @@ export default function ServiceForm({
|
|||||||
image:
|
image:
|
||||||
values.image.length > 0
|
values.image.length > 0
|
||||||
? values.image
|
? values.image
|
||||||
: `registry.${currentProject.region.name}.${currentProject.region.domain}/${newServiceID}`,
|
: `registry.${currentProject.region.name}.${currentProject.region.domain}/<uuid-to-be-generated-on-creation>`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setDetailsServiceId(newServiceID);
|
setDetailsServiceId(newServiceID);
|
||||||
setDetailsServiceSubdomain(subdomain);
|
setDetailsServiceSubdomain('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,7 +311,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Name of the service, must be unique per project.">
|
<Tooltip title="Name of the service, must be unique per project.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -362,7 +351,7 @@ export default function ServiceForm({
|
|||||||
>
|
>
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -393,7 +382,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -441,7 +430,7 @@ export default function ServiceForm({
|
|||||||
{createServiceFormError && (
|
{createServiceFormError && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="error"
|
severity="error"
|
||||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
<strong>Error:</strong> {createServiceFormError.message}
|
<strong>Error:</strong> {createServiceFormError.message}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user