Compare commits
46 Commits
@nhost/rea
...
@nhost/das
| 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:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.10.5
|
||||
version: 9.15.0
|
||||
run_install: false
|
||||
- name: Get pnpm cache directory
|
||||
id: pnpm-cache-dir
|
||||
shell: bash
|
||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
id: pnpm-cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
- name: Use Node.js v18
|
||||
- name: Use Node.js v20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
- shell: bash
|
||||
name: Install packages
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
26
.github/workflows/changesets.yaml
vendored
26
.github/workflows/changesets.yaml
vendored
@@ -65,29 +65,13 @@ jobs:
|
||||
|
||||
publish-vercel:
|
||||
name: Publish to Vercel
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- test
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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 }}
|
||||
uses: ./.github/workflows/deploy-dashboard.yaml
|
||||
with:
|
||||
git_ref: ${{ github.ref_name }}
|
||||
environment: production
|
||||
secrets: inherit
|
||||
|
||||
publish-docker:
|
||||
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
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
||||
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
||||
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_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
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
|
||||
|
||||
jobs:
|
||||
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:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@v0.24
|
||||
uses: Codium-ai/pr-agent@v0.26
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -42,7 +42,6 @@ module.exports = {
|
||||
env: (config) => ({
|
||||
...config,
|
||||
NEXT_PUBLIC_ENV: 'dev',
|
||||
NEXT_PUBLIC_NHOST_BACKEND_URL: 'http://localhost:1337',
|
||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,68 @@
|
||||
# @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
|
||||
|
||||
### 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 update
|
||||
WORKDIR /app
|
||||
|
||||
RUN yarn global add turbo@1.11.3
|
||||
RUN yarn global add turbo@2.2.3
|
||||
COPY . .
|
||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||
|
||||
FROM node:18-alpine AS builder
|
||||
FROM node:20-alpine AS builder
|
||||
ARG TURBO_TOKEN
|
||||
ARG TURBO_TEAM
|
||||
|
||||
@@ -15,22 +15,22 @@ RUN apk add --no-cache libc6-compat python3 make g++
|
||||
RUN apk update
|
||||
WORKDIR /app
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_ENV dev
|
||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV NEXT_PUBLIC_ENV=dev
|
||||
ENV NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
|
||||
# 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_AUTH_URL __NEXT_PUBLIC_NHOST_AUTH_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_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_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_CONFIGSERVER_URL __NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
||||
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_FUNCTIONS_URL=__NEXT_PUBLIC_NHOST_FUNCTIONS_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_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_API_URL=__NEXT_PUBLIC_NHOST_HASURA_API_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 --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
||||
@@ -41,7 +41,7 @@ COPY turbo.json turbo.json
|
||||
COPY config/ config/
|
||||
RUN pnpm build:dashboard
|
||||
|
||||
FROM node:18-alpine AS runner
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
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
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["node", "dashboard/server.js"]
|
||||
CMD ["node", "dashboard/server.js"]
|
||||
@@ -100,7 +100,6 @@ pnpm storybook --port 6007
|
||||
|
||||
| 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_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`. |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.7.2",
|
||||
"version": "2.13.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev",
|
||||
"dev": "RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false next dev",
|
||||
"build": "next build --no-lint",
|
||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||
"start": "next start",
|
||||
@@ -84,7 +84,7 @@
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "^14.2.10",
|
||||
"next": "^14.2.22",
|
||||
"next-nprogress-bar": "^2.3.13",
|
||||
"next-seo": "^6.5.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;
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
* Determines whether or not the link should be active if href matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
@@ -87,7 +86,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
<>
|
||||
<Backdrop
|
||||
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"
|
||||
tabIndex={-1}
|
||||
onClick={() => setExpanded(false)}
|
||||
@@ -104,7 +103,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
<Box
|
||||
component="aside"
|
||||
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',
|
||||
className,
|
||||
)}
|
||||
@@ -119,6 +118,7 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
>
|
||||
Auto-Embeddings
|
||||
</AINavLink>
|
||||
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
|
||||
@@ -21,22 +21,9 @@ import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoun
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
type DetailedHTMLProps,
|
||||
type HTMLProps,
|
||||
} from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface AuthenticatedLayoutProps extends BaseLayoutProps {
|
||||
/**
|
||||
* Props passed to the internal content container.
|
||||
*/
|
||||
contentContainerProps?: DetailedHTMLProps<
|
||||
HTMLProps<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
}
|
||||
export interface AuthenticatedLayoutProps extends BaseLayoutProps {}
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
children,
|
||||
|
||||
@@ -8,20 +8,15 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/v3/hover-card';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { ProjectStatusIndicator } from '@/features/orgs/components/common/ProjectStatusIndicator';
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { Box, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -31,56 +26,6 @@ type Option = {
|
||||
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() {
|
||||
const {
|
||||
query: { appSubdomain },
|
||||
|
||||
@@ -21,23 +21,22 @@ export default function UnauthenticatedLayout({
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const isOnResetPassword = router.route === '/password/reset';
|
||||
|
||||
useEffect(() => {
|
||||
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 (
|
||||
<BaseLayout {...props}>
|
||||
<LoadingScreen
|
||||
sx={{ backgroundColor: (theme) => theme.palette.background.default }}
|
||||
slotProps={{
|
||||
activityIndicator: {
|
||||
className: 'text-white',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</BaseLayout>
|
||||
);
|
||||
@@ -59,19 +58,19 @@ export default function UnauthenticatedLayout({
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<Box
|
||||
className="flex items-center min-h-screen"
|
||||
className="flex min-h-screen items-center"
|
||||
sx={{ backgroundColor: (theme) => theme.palette.common.black }}
|
||||
>
|
||||
<Container
|
||||
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">
|
||||
{children}
|
||||
</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="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
|
||||
priority
|
||||
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 { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||
import { PlusIcon, Search } from 'lucide-react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { Dialog, DialogContent } from '@/components/ui/v3/dialog';
|
||||
@@ -26,7 +26,7 @@ interface CommandDialogProps extends DialogProps {}
|
||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
return (
|
||||
<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">
|
||||
{children}
|
||||
</Command>
|
||||
@@ -37,14 +37,22 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center px-3 border-b" cmdk-input-wrapper="">
|
||||
<Search className="w-4 h-4 mr-2 opacity-50 shrink-0" />
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
|
||||
prefix?: React.ReactNode;
|
||||
}
|
||||
>(({ 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
|
||||
ref={ref}
|
||||
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',
|
||||
prefix && 'pl-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -73,7 +81,7 @@ const CommandEmpty = React.forwardRef<
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-sm text-center"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -140,6 +148,25 @@ const 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 {
|
||||
Command,
|
||||
CommandDialog,
|
||||
@@ -150,4 +177,5 @@ export {
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
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 { Form } from '@/components/form/Form';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
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 { GraphqlDataSourcesFormSection } from '@/features/ai/AssistantForm/components/GraphqlDataSourcesFormSection';
|
||||
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 { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} 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 { useEffect } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -28,6 +29,7 @@ export const validationSchema = Yup.object({
|
||||
description: Yup.string(),
|
||||
instructions: Yup.string().required('The instructions are required'),
|
||||
model: Yup.string().required('The model is required'),
|
||||
fileStore: Yup.string().label('File Store'),
|
||||
graphql: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
@@ -64,14 +66,14 @@ export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* if there is initialData then it's an update operation
|
||||
*/
|
||||
initialData?: AssistantFormValues;
|
||||
initialData?: AssistantFormValues
|
||||
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
@@ -114,26 +116,26 @@ export default function AssistantForm({
|
||||
} = form;
|
||||
|
||||
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
const createOrUpdateAssistant = async (
|
||||
values: DeepRequired<AssistantFormValues> & {
|
||||
assistantID: string;
|
||||
},
|
||||
) => {
|
||||
// remove any __typename from the form values
|
||||
const payload = removeTypename(values);
|
||||
|
||||
if (values.webhooks.length === 0) {
|
||||
if (values.webhooks?.length === 0) {
|
||||
delete payload.webhooks;
|
||||
}
|
||||
|
||||
if (values.graphql.length === 0) {
|
||||
if (values.graphql?.length === 0) {
|
||||
delete payload.graphql;
|
||||
}
|
||||
|
||||
// remove assistantId because the update mutation fails otherwise
|
||||
delete payload.assistantID;
|
||||
|
||||
// If the assistantId is set then we do an update
|
||||
@@ -158,11 +160,13 @@ export default function AssistantForm({
|
||||
};
|
||||
|
||||
const handleSubmit = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
values: DeepRequired<AssistantFormValues> & {
|
||||
assistantID: string;
|
||||
},
|
||||
) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await createOrUpdateAutoEmbeddings(values);
|
||||
await createOrUpdateAssistant(values);
|
||||
onSubmit?.();
|
||||
},
|
||||
{
|
||||
@@ -282,6 +286,7 @@ export default function AssistantForm({
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<GraphqlDataSourcesFormSection />
|
||||
<WebhooksDataSourcesFormSection />
|
||||
</div>
|
||||
|
||||
@@ -15,12 +15,12 @@ import { type Assistant } from 'pages/[workspaceSlug]/[appSlug]/ai/assistants';
|
||||
|
||||
interface AssistantsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
* The list of assistants.
|
||||
*/
|
||||
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()}
|
||||
*/
|
||||
|
||||
@@ -14,9 +14,6 @@ export default function Estimate() {
|
||||
|
||||
const amountDue = useMemo(() => {
|
||||
const amount = data?.billingGetNextInvoice?.AmountDue;
|
||||
if (!amount) {
|
||||
return 'N/A';
|
||||
}
|
||||
if (typeof amount !== 'number') {
|
||||
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 { Input } from '@/components/ui/v2/Input';
|
||||
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 { DeploymentStatusMessage } from '@/features/projects/deployments/components/DeploymentStatusMessage';
|
||||
import {
|
||||
useGetProjectsQuery,
|
||||
type GetProjectsQuery,
|
||||
@@ -22,20 +23,21 @@ function ProjectCard({ project }: { project: Project }) {
|
||||
return (
|
||||
<Link
|
||||
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 w-full flex-row items-center space-x-2">
|
||||
<Box className="h-6 w-6 flex-shrink-0" />
|
||||
<p className="truncate text-lg font-bold">{project.name}</p>
|
||||
<div className="flex flex-row items-start gap-2">
|
||||
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex w-full flex-col">
|
||||
<p className="truncate font-bold">{project.name}</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.region.name}
|
||||
</span>
|
||||
</div>
|
||||
<ProjectStatusIndicator status={project.appStates[0].stateId} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start gap-2">
|
||||
<DeploymentStatusMessage
|
||||
appCreatedAt={project.createdAt}
|
||||
deployment={latestDeployment}
|
||||
/>
|
||||
<div className="flex flex-1 flex-row items-start gap-2">
|
||||
<DeploymentStatusMessage deployment={latestDeployment} />
|
||||
</div>
|
||||
|
||||
<div className="flex w-full justify-end">
|
||||
@@ -53,6 +55,7 @@ export default function ProjectsGrid() {
|
||||
orgSlug: org?.slug,
|
||||
},
|
||||
skip: !org,
|
||||
pollInterval: 10 * 1000,
|
||||
});
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
@@ -100,7 +103,7 @@ export default function ProjectsGrid() {
|
||||
</Button>
|
||||
</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) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useMemo } from 'react';
|
||||
* @returns A function that returns a new ApolloClient instance.
|
||||
*/
|
||||
export default function useRemoteApplicationGQLClient() {
|
||||
const { project, loading } = useProject({ target: 'user-project' });
|
||||
const { project, loading } = useProject();
|
||||
const serviceUrl = generateAppServiceUrl(
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
|
||||
@@ -128,6 +128,9 @@ export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
<AINavLink href="/file-stores" exact={false} onClick={handleSelect}>
|
||||
File Stores
|
||||
</AINavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { ApplicationUnknown } from '@/features/orgs/projects/common/components/A
|
||||
import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
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 { NextSeo } from 'next-seo';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -37,7 +37,7 @@ function ProjectLayoutContent({
|
||||
|
||||
const { state } = useAppState();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { project, loading, error } = useProject({ poll: true });
|
||||
const { project, loading, error } = useProjectWithState();
|
||||
|
||||
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 { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { removeTypename, type DeepRequired } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertAssistantMutation,
|
||||
useUpdateAssistantMutation,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
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';
|
||||
|
||||
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({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
description: Yup.string(),
|
||||
instructions: Yup.string().required('The instructions are required'),
|
||||
model: Yup.string().required('The model is required'),
|
||||
fileStore: Yup.string().label('File Store'),
|
||||
graphql: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
name: Yup.string().required(),
|
||||
@@ -65,14 +71,17 @@ export type AssistantFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -87,6 +96,7 @@ export interface AssistantFormProps extends DialogFormProps {
|
||||
export default function AssistantForm({
|
||||
assistantId,
|
||||
initialData,
|
||||
fileStores,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
location,
|
||||
@@ -103,8 +113,27 @@ export default function AssistantForm({
|
||||
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>({
|
||||
defaultValues: initialData,
|
||||
defaultValues: formDefaultValues,
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
@@ -120,22 +149,32 @@ export default function AssistantForm({
|
||||
onDirtyStateChange(isDirty, location);
|
||||
}, [isDirty, location, onDirtyStateChange]);
|
||||
|
||||
const createOrUpdateAutoEmbeddings = async (
|
||||
values: DeepRequired<AssistantFormValues> & { assistantID: string },
|
||||
const createOrUpdateAssistant = async (
|
||||
values: DeepRequired<AssistantFormValues> & {
|
||||
assistantID: string;
|
||||
},
|
||||
) => {
|
||||
// remove any __typename from the form values
|
||||
const payload = removeTypename(values);
|
||||
|
||||
if (values.webhooks.length === 0) {
|
||||
if (values.webhooks?.length === 0) {
|
||||
delete payload.webhooks;
|
||||
}
|
||||
|
||||
if (values.graphql.length === 0) {
|
||||
if (values.graphql?.length === 0) {
|
||||
delete payload.graphql;
|
||||
}
|
||||
|
||||
if (isFileStoreSupported && values.fileStore) {
|
||||
payload.fileStores = [values.fileStore];
|
||||
}
|
||||
if (!isFileStoreSupported) {
|
||||
delete payload.fileStores;
|
||||
}
|
||||
|
||||
// remove assistantId because the update mutation fails otherwise
|
||||
delete payload.assistantID;
|
||||
delete payload.fileStore;
|
||||
|
||||
// If the assistantId is set then we do an update
|
||||
if (assistantId) {
|
||||
@@ -152,7 +191,7 @@ export default function AssistantForm({
|
||||
await insertAssistantMutation({
|
||||
variables: {
|
||||
data: {
|
||||
...values,
|
||||
...payload,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -163,7 +202,7 @@ export default function AssistantForm({
|
||||
) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await createOrUpdateAutoEmbeddings(values);
|
||||
await createOrUpdateAssistant(values);
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
@@ -285,6 +328,36 @@ export default function AssistantForm({
|
||||
/>
|
||||
<GraphqlDataSourcesFormSection />
|
||||
<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>
|
||||
|
||||
<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 { DeleteAssistantModal } from '@/features/orgs/projects/ai/DeleteAssistantModal';
|
||||
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';
|
||||
|
||||
interface AssistantsListProps {
|
||||
/**
|
||||
* The run services fetched from entering the users page.
|
||||
* The list of assistants
|
||||
*/
|
||||
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()}
|
||||
*/
|
||||
@@ -35,6 +41,7 @@ interface AssistantsListProps {
|
||||
|
||||
export default function AssistantsList({
|
||||
assistants,
|
||||
fileStores,
|
||||
onCreateOrUpdate,
|
||||
onDelete,
|
||||
}: AssistantsListProps) {
|
||||
@@ -49,6 +56,7 @@ export default function AssistantsList({
|
||||
initialData={{
|
||||
...assistant,
|
||||
}}
|
||||
fileStores={fileStores}
|
||||
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
|
||||
.object({
|
||||
sender: yup.string().label('SMTP Sender').email().required(),
|
||||
sender: yup.string().label('SMTP Sender').required(),
|
||||
password: yup.string().label('Password').required(),
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -30,7 +30,7 @@ const smtpValidationSchema = yup
|
||||
user: yup.string().label('Username').required(),
|
||||
password: yup.string().label('Password'),
|
||||
method: yup.string().required(),
|
||||
sender: yup.string().label('SMTP Sender').email().required(),
|
||||
sender: yup.string().label('SMTP Sender').required(),
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
@@ -16,18 +16,20 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import { EditUserPasswordForm } from '@/features/orgs/projects/authentication/users/components/EditUserPasswordForm';
|
||||
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 { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
@@ -106,6 +108,8 @@ export default function EditUserForm({
|
||||
onDeleteUser,
|
||||
roles,
|
||||
}: EditUserFormProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const theme = useTheme();
|
||||
const { onDirtyStateChange, openDialog } = useDialog();
|
||||
const { project } = useProject();
|
||||
@@ -196,6 +200,7 @@ export default function EditUserForm({
|
||||
|
||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const allAvailableProjectRoles = getUserRoles(
|
||||
@@ -206,6 +211,7 @@ export default function EditUserForm({
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
},
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
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 type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||
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 { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
@@ -61,6 +63,8 @@ export interface UsersBodyProps {
|
||||
|
||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||
const { project } = useProject();
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
@@ -88,6 +92,7 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
*/
|
||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
||||
|
||||
@@ -36,8 +36,8 @@ export default function ApplicationPaused() {
|
||||
>
|
||||
<RemoveApplicationModal
|
||||
close={() => setShowDeletingModal(false)}
|
||||
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
|
||||
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
|
||||
recover the app once it has been deleted.`}
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ export default function useAppState(): {
|
||||
state: ApplicationStatus;
|
||||
message?: string;
|
||||
} {
|
||||
const { project } = useProject({ poll: true });
|
||||
const { project } = useProjectWithState();
|
||||
const noApplication = !project;
|
||||
|
||||
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({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: project?.id },
|
||||
skip: !project.id,
|
||||
skip: !project?.id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -65,8 +65,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
||||
<ColumnAutocomplete
|
||||
{...args}
|
||||
name="firstReference"
|
||||
label="First Reference"
|
||||
onChange={(_event, newValue) =>
|
||||
onChange={(newValue) =>
|
||||
form.setValue('firstReference', newValue.value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
@@ -80,8 +79,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
||||
<ColumnAutocomplete
|
||||
{...args}
|
||||
name="secondReference"
|
||||
label="Second Reference"
|
||||
onChange={(_event, newValue) =>
|
||||
onChange={(newValue) =>
|
||||
form.setValue('secondReference', newValue.value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
tableQuery,
|
||||
hasuraMetadataQuery,
|
||||
@@ -21,17 +25,9 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
test('should render a combobox', () => {
|
||||
render(
|
||||
<ColumnAutocomplete
|
||||
schema="public"
|
||||
table="books"
|
||||
label="Column Autocomplete"
|
||||
/>,
|
||||
);
|
||||
render(<ColumnAutocomplete schema="public" table="books" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /column autocomplete/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// 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 { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
||||
import { AutocompletePopper } from '@/components/ui/v2/Autocomplete';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ArrowLeftIcon } from '@/components/ui/v2/icons/ArrowLeftIcon';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { OptionBase } from '@/components/ui/v2/Option';
|
||||
import { OptionGroupBase } from '@/components/ui/v2/OptionGroup';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Button, type ButtonProps } 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 { useMetadataQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useMetadataQuery';
|
||||
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
||||
import { getTruncatedText } from '@/utils/getTruncatedText';
|
||||
import type { AutocompleteGroupedOption } from '@mui/base/useAutocomplete';
|
||||
import { useAutocomplete } from '@mui/base/useAutocomplete';
|
||||
import type { AutocompleteRenderGroupParams } from '@mui/material/Autocomplete';
|
||||
import { autocompleteClasses } from '@mui/material/Autocomplete';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ForwardedRef,
|
||||
HTMLAttributes,
|
||||
PropsWithoutRef,
|
||||
SyntheticEvent,
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronLeft, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import useRuleGroupEditor from '@/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/useRuleGroupEditor';
|
||||
import { CommandLoading } from 'cmdk';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { UseAsyncValueOptions } from './useAsyncValue';
|
||||
import useAsyncValue from './useAsyncValue';
|
||||
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
||||
import useColumnGroups from './useColumnGroups';
|
||||
|
||||
export interface ColumnAutocompleteProps
|
||||
extends Omit<PropsWithoutRef<InputProps>, 'onChange'> {
|
||||
export interface ColumnAutocompleteProps extends Omit<ButtonProps, 'onChange'> {
|
||||
value?: string;
|
||||
/**
|
||||
* Schema where the `table` is located.
|
||||
*/
|
||||
@@ -45,70 +39,39 @@ export interface ColumnAutocompleteProps
|
||||
/**
|
||||
* Function to be called when the value changes.
|
||||
*/
|
||||
onChange?: (
|
||||
event: SyntheticEvent,
|
||||
value: {
|
||||
value: string;
|
||||
columnMetadata?: Record<string, any>;
|
||||
disableReset?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onChange?: (value: {
|
||||
value: string;
|
||||
columnMetadata?: Record<string, any>;
|
||||
disableReset?: boolean;
|
||||
}) => void;
|
||||
/**
|
||||
* Function to be called when the input is asynchronously initialized.
|
||||
*/
|
||||
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
||||
/**
|
||||
* Class name to be applied to the root element.
|
||||
*/
|
||||
rootClassName?: string;
|
||||
/**
|
||||
* Determines if the autocomplete should allow relationships.
|
||||
*/
|
||||
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(
|
||||
{
|
||||
rootClassName,
|
||||
schema: defaultSchema,
|
||||
table: defaultTable,
|
||||
value: externalValue,
|
||||
disableRelationships,
|
||||
onChange,
|
||||
onInitialized,
|
||||
...props
|
||||
}: ColumnAutocompleteProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const { disabled } = useRuleGroupEditor();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [activeRelationship, setActiveRelationship] = useState<{
|
||||
schema: string;
|
||||
table: string;
|
||||
@@ -120,7 +83,6 @@ function ColumnAutocomplete(
|
||||
const {
|
||||
data: tableData,
|
||||
status: tableStatus,
|
||||
error: tableError,
|
||||
isFetching: isTableFetching,
|
||||
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
||||
schema: selectedSchema,
|
||||
@@ -132,7 +94,6 @@ function ColumnAutocomplete(
|
||||
const {
|
||||
data: metadata,
|
||||
status: metadataStatus,
|
||||
error: metadataError,
|
||||
isFetching: isMetadataFetching,
|
||||
} = useMetadataQuery([`default.metadata`], {
|
||||
queryOptions: { refetchOnWindowFocus: false },
|
||||
@@ -140,8 +101,6 @@ function ColumnAutocomplete(
|
||||
|
||||
const {
|
||||
initialized,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
selectedColumn,
|
||||
setSelectedColumn,
|
||||
selectedRelationships,
|
||||
@@ -159,57 +118,20 @@ function ColumnAutocomplete(
|
||||
onInitialized,
|
||||
});
|
||||
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setPages(
|
||||
relationshipDotNotation ? [relationshipDotNotation?.split('.')[0]] : [],
|
||||
);
|
||||
}, [relationshipDotNotation]);
|
||||
|
||||
const activePage = pages[pages.length - 1];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveRelationship(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({
|
||||
selectedSchema,
|
||||
selectedTable,
|
||||
@@ -218,246 +140,214 @@ function ColumnAutocomplete(
|
||||
disableRelationships,
|
||||
});
|
||||
|
||||
const {
|
||||
popupOpen,
|
||||
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,
|
||||
});
|
||||
const handleChange = (newValue: string) => {
|
||||
const selectedOption = options.find((option) => option.value === newValue);
|
||||
|
||||
function handleInputValueChange(
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
const { value } = event.target;
|
||||
setInputValue(value);
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedColumn({
|
||||
value,
|
||||
label: value,
|
||||
metadata: selectedColumn?.metadata || {
|
||||
table_schema: selectedSchema,
|
||||
table_name: selectedTable,
|
||||
},
|
||||
});
|
||||
setSelectedColumn(selectedOption);
|
||||
setOpen(false);
|
||||
setValue(newValue === value ? '' : newValue);
|
||||
|
||||
onChange?.(event, {
|
||||
const valueObj = {
|
||||
value:
|
||||
selectedRelationships.length > 0
|
||||
? [relationshipDotNotation, value].join('.')
|
||||
: value,
|
||||
columnMetadata: {
|
||||
table_schema: selectedSchema,
|
||||
table_name: selectedTable,
|
||||
},
|
||||
});
|
||||
}
|
||||
? [relationshipDotNotation, newValue].join('.')
|
||||
: newValue,
|
||||
columnMetadata: selectedOption.metadata,
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div {...getRootProps()} className={rootClassName}>
|
||||
<Input
|
||||
{...props}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
...(props.slotProps || {}),
|
||||
label: getInputLabelProps(),
|
||||
input: {
|
||||
...(props.slotProps?.input || {}),
|
||||
ref: setAnchorEl,
|
||||
sx: [
|
||||
...(Array.isArray(props.slotProps?.input?.sx)
|
||||
? props.slotProps.input.sx
|
||||
: [props.slotProps?.input?.sx || {}]),
|
||||
{
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
inputRoot: {
|
||||
...getInputProps(),
|
||||
className: twMerge(
|
||||
Boolean(selectedColumn) || Boolean(relationshipDotNotation)
|
||||
? '!pl-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 }}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
{buttonPrefix ? (
|
||||
<div className="flex flex-shrink-0 gap-0 truncate">
|
||||
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||
{buttonPrefix}.
|
||||
</span>
|
||||
{selectedColumn?.label}
|
||||
</div>
|
||||
) : (
|
||||
selectedColumn?.label || 'Select a column'
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Box
|
||||
className={autocompleteClasses.paper}
|
||||
sx={{
|
||||
borderWidth: (theme) => (theme.palette.mode === 'dark' ? 1 : 0),
|
||||
borderColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.400' : 'none',
|
||||
<Command
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||
e.preventDefault();
|
||||
setPages((p) => p.slice(0, -1));
|
||||
setSelectedColumn(null);
|
||||
setSelectedRelationships((activeRelationships) =>
|
||||
activeRelationships.slice(0, -1),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="grid grid-flow-col items-center justify-start gap-2 border-b-1 px-3 py-2.5"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
{selectedRelationships.length > 0 && (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setInputValue('');
|
||||
setSelectedColumn(null);
|
||||
setSelectedRelationships((activeRelationships) =>
|
||||
activeRelationships.slice(0, -1),
|
||||
);
|
||||
}}
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
autoFocus
|
||||
placeholder=""
|
||||
prefix={
|
||||
relationshipDotNotation
|
||||
? `
|
||||
${selectedTable}.${relationshipDotNotation}.`
|
||||
: ``
|
||||
}
|
||||
/>
|
||||
{pages?.length > 0 ? (
|
||||
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleBackRelationship}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Text className="direction-rtl truncate text-left">
|
||||
<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..." />
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="py-1.5 text-sm text-muted-foreground">
|
||||
{defaultTable}.{pages.join('.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedOptions.length > 0 && (
|
||||
<List
|
||||
{...getListboxProps()}
|
||||
className={autocompleteClasses.listbox}
|
||||
>
|
||||
{(
|
||||
groupedOptions as AutocompleteGroupedOption<
|
||||
(typeof options)[number]
|
||||
>[]
|
||||
).map((optionGroup) =>
|
||||
renderGroup({
|
||||
key: `${optionGroup.key}`,
|
||||
group: optionGroup.group,
|
||||
children: optionGroup.options.map((option, index) =>
|
||||
renderOption(
|
||||
option,
|
||||
getOptionProps({
|
||||
option,
|
||||
index: optionGroup.index + index,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{groupedOptions.length === 0 && Boolean(anchorEl) && (
|
||||
<Text className={autocompleteClasses.noOptions}>No options</Text>
|
||||
)}
|
||||
</Box>
|
||||
</AutocompletePopper>
|
||||
</>
|
||||
) : null}
|
||||
<CommandList>
|
||||
{!activePage && (
|
||||
<>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
{tableStatus === 'loading' ||
|
||||
metadataStatus === 'loading' ||
|
||||
(!initialized && <CommandLoading>Loading...</CommandLoading>)}
|
||||
<CommandGroup heading="columns">
|
||||
{columns.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={handleChange}
|
||||
className="overflow-x-hidden"
|
||||
>
|
||||
<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>
|
||||
{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,
|
||||
}: UseAsyncValueOptions) {
|
||||
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
// 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
|
||||
@@ -131,7 +130,6 @@ export default function useAsyncValue({
|
||||
),
|
||||
});
|
||||
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
||||
setInputValue(activeColumn);
|
||||
}, [
|
||||
remainingColumnPath,
|
||||
isTableLoading,
|
||||
@@ -287,8 +285,6 @@ export default function useAsyncValue({
|
||||
|
||||
return {
|
||||
initialized,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
activeRelationship,
|
||||
selectedRelationships: initialized ? selectedRelationships : [],
|
||||
selectedColumn: initialized ? selectedColumn : null,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
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 { InlineCode } from '@/components/presentational/InlineCode';
|
||||
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
||||
@@ -23,11 +17,19 @@ import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGri
|
||||
import {
|
||||
POSTGRESQL_CHARACTER_TYPES,
|
||||
POSTGRESQL_DATE_TIME_TYPES,
|
||||
POSTGRESQL_DECIMAL_TYPES,
|
||||
POSTGRESQL_INTEGER_TYPES,
|
||||
POSTGRESQL_JSON_TYPES,
|
||||
POSTGRESQL_NUMERIC_TYPES,
|
||||
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
|
||||
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 dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -68,10 +70,10 @@ export function createDataGridColumn(
|
||||
|
||||
const defaultColumnConfiguration = {
|
||||
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" />}
|
||||
|
||||
<span className="font-bold truncate" title={column.column_name}>
|
||||
<span className="truncate font-bold" title={column.column_name}>
|
||||
{column.column_name}
|
||||
</span>
|
||||
|
||||
@@ -104,12 +106,21 @@ export function createDataGridColumn(
|
||||
foreignKeyRelation: column.foreign_key_relation,
|
||||
};
|
||||
|
||||
if (POSTGRESQL_NUMERIC_TYPES.includes(column.data_type)) {
|
||||
if (POSTGRESQL_INTEGER_TYPES.includes(column.data_type)) {
|
||||
return {
|
||||
...defaultColumnConfiguration,
|
||||
type: 'number',
|
||||
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}
|
||||
slotProps={{
|
||||
label: commonLabelProps,
|
||||
inputRoot: { step: 1 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -326,10 +326,10 @@ export default function RolePermissionEditorForm({
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{error && error instanceof Error && (
|
||||
<div className="px-6 mb-4 -mt-3">
|
||||
<div className="-mt-3 mb-4 px-6">
|
||||
<Alert
|
||||
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">
|
||||
<strong>Error:</strong> {error.message}
|
||||
@@ -349,13 +349,13 @@ export default function RolePermissionEditorForm({
|
||||
|
||||
<Form
|
||||
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' }}
|
||||
>
|
||||
<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
|
||||
title="Selected role & action"
|
||||
className="justify-between grid-flow-col"
|
||||
className="grid-flow-col justify-between"
|
||||
>
|
||||
<div className="grid grid-flow-col gap-4">
|
||||
<Text>
|
||||
@@ -408,7 +408,7 @@ export default function RolePermissionEditorForm({
|
||||
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
||||
</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
|
||||
variant="borderless"
|
||||
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 type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import OperatorComboBox from './OperatorComboBox';
|
||||
import RuleRemoveButton from './RuleRemoveButton';
|
||||
import RuleValueInput from './RuleValueInput';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
@@ -25,69 +22,6 @@ export interface RuleEditorRowProps
|
||||
* Function to be called when the remove button is clicked.
|
||||
*/
|
||||
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({
|
||||
@@ -95,17 +29,12 @@ export default function RuleEditorRow({
|
||||
index,
|
||||
onRemove,
|
||||
className,
|
||||
disabledOperators = [],
|
||||
...props
|
||||
}: RuleEditorRowProps) {
|
||||
const { schema, table, disabled } = useRuleGroupEditor();
|
||||
const { control, setValue, getFieldState } = useFormContext();
|
||||
const { schema, table } = useRuleGroupEditor();
|
||||
const { control, setValue } = useFormContext();
|
||||
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 [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
||||
const { field: autocompleteField } = useController({
|
||||
@@ -113,48 +42,19 @@ export default function RuleEditorRow({
|
||||
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 (
|
||||
<div
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ColumnAutocomplete
|
||||
{...autocompleteField}
|
||||
disabled={disabled}
|
||||
schema={schema}
|
||||
table={table}
|
||||
rootClassName="h-10"
|
||||
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 }) => {
|
||||
onChange={({ value, columnMetadata, disableReset }) => {
|
||||
setSelectedTablePath(
|
||||
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
||||
);
|
||||
@@ -182,69 +82,21 @@ export default function RuleEditorRow({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<ControlledSelect
|
||||
disabled={disabled}
|
||||
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>
|
||||
|
||||
<OperatorComboBox
|
||||
name={rowName}
|
||||
selectedColumnType={selectedColumnType}
|
||||
/>
|
||||
<RuleValueInput
|
||||
selectedTablePath={selectedTablePath}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
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 useRuleGroupEditor from './useRuleGroupEditor';
|
||||
|
||||
@@ -32,9 +37,11 @@ export default function RuleGroupControls({
|
||||
...props
|
||||
}: RuleGroupControlsProps) {
|
||||
const { disabled } = useRuleGroupEditor();
|
||||
const inputName = `${name}.operator`;
|
||||
const currentOperator: RuleGroup['operator'] = useWatch({
|
||||
name: `${name}.operator`,
|
||||
name: inputName,
|
||||
});
|
||||
const { setValue } = useFormContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -42,24 +49,26 @@ export default function RuleGroupControls({
|
||||
{...props}
|
||||
>
|
||||
{showSelect ? (
|
||||
<ControlledSelect
|
||||
<Select
|
||||
disabled={disabled}
|
||||
name={`${name}.operator`}
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.grey[300]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
},
|
||||
name={inputName}
|
||||
onValueChange={(newValue: string) => {
|
||||
setValue(inputName, newValue, { shouldDirty: true });
|
||||
}}
|
||||
fullWidth
|
||||
defaultValue={currentOperator}
|
||||
>
|
||||
<Option value="_and">and</Option>
|
||||
<Option value="_or">or</Option>
|
||||
</ControlledSelect>
|
||||
<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">
|
||||
<SelectValue />
|
||||
</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">
|
||||
{operatorDictionary[currentOperator]}
|
||||
|
||||
@@ -89,9 +89,3 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {};
|
||||
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 { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { RuleEditorRowProps } from './RuleEditorRow';
|
||||
import RuleEditorRow from './RuleEditorRow';
|
||||
import RuleGroupControls from './RuleGroupControls';
|
||||
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
||||
|
||||
export interface RuleGroupEditorProps
|
||||
extends BoxProps,
|
||||
Pick<RuleEditorRowProps, 'disabledOperators'> {
|
||||
export interface RuleGroupEditorProps extends BoxProps {
|
||||
/**
|
||||
* Determines whether or not the rule group editor is disabled.
|
||||
*/
|
||||
@@ -63,7 +60,6 @@ export default function RuleGroupEditor({
|
||||
name,
|
||||
className,
|
||||
disableRemove,
|
||||
disabledOperators = [],
|
||||
depth = 0,
|
||||
maxDepth,
|
||||
schema,
|
||||
@@ -115,7 +111,7 @@ export default function RuleGroupEditor({
|
||||
<Box
|
||||
{...props}
|
||||
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,
|
||||
)}
|
||||
sx={[
|
||||
@@ -147,7 +143,6 @@ export default function RuleGroupEditor({
|
||||
name={name}
|
||||
index={ruleIndex}
|
||||
onRemove={() => removeRule(ruleIndex)}
|
||||
disabledOperators={disabledOperators}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -177,7 +172,6 @@ export default function RuleGroupEditor({
|
||||
table={table}
|
||||
onRemove={() => removeGroup(ruleGroupIndex)}
|
||||
disableRemove={rules.length === 0 && groups.length === 1}
|
||||
disabledOperators={disabledOperators}
|
||||
name={`${name}.groups.${ruleGroupIndex}`}
|
||||
depth={depth + 1}
|
||||
disabled={disabled}
|
||||
@@ -247,7 +241,7 @@ export default function RuleGroupEditor({
|
||||
{onRemove && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
color="error"
|
||||
onClick={onRemove}
|
||||
disabled={disableRemove}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||
import type {
|
||||
Rule,
|
||||
RuleGroup,
|
||||
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import { X } from 'lucide-react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -34,9 +33,9 @@ function RuleRemoveButton({
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={twMerge('h-10 !min-w-0 lg:!rounded-l-none', className)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={twMerge('h-10 !min-w-0', className)}
|
||||
disabled={
|
||||
disabled ||
|
||||
(rules.length === 1 && !groups?.length && !unsupported?.length)
|
||||
@@ -44,18 +43,8 @@ function RuleRemoveButton({
|
||||
{...props}
|
||||
aria-label="Remove Rule"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
import { inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import type { ColumnAutocompleteProps } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandCreateItem,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
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 { 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 { cn } from '@/lib/utils';
|
||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { CommandLoading } from 'cmdk';
|
||||
import { useState } from 'react';
|
||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
|
||||
@@ -41,23 +65,7 @@ function ColumnSelectorInput({
|
||||
schema={schema}
|
||||
table={table}
|
||||
disableRelationships
|
||||
slotProps={{
|
||||
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 }) => {
|
||||
onChange={({ value }) => {
|
||||
if (selectedTablePath === `${schema}.${table}`) {
|
||||
setValue(name, [value], { shouldDirty: true });
|
||||
return;
|
||||
@@ -75,113 +83,92 @@ export interface RuleValueInputProps {
|
||||
* Name of the parent group editor.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Class name to apply to the input wrapper.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Path of the table selected through the column input.
|
||||
*/
|
||||
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({
|
||||
name,
|
||||
selectedTablePath,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
}: RuleValueInputProps) {
|
||||
const { schema, table, disabled } = useRuleGroupEditor();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { setValue } = useFormContext();
|
||||
const { project } = useProject();
|
||||
const { setValue, control } = useFormContext();
|
||||
const inputName = `${name}.value`;
|
||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
|
||||
const sharedInputSx: InputProps['sx'] = !disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey[300]
|
||||
: theme.palette.common.white,
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const { field } = useController({
|
||||
name: inputName,
|
||||
control,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error: customClaimsError,
|
||||
} = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !isHasuraInput || !currentProject?.id,
|
||||
const [open, setOpen] = useState(false);
|
||||
const comboboxValue = useWatch({ name: inputName });
|
||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
const { data, loading } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
skip: !project?.id,
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
if (operator === '_is_null') {
|
||||
const defaultValue = !Array.isArray(comboboxValue) ? comboboxValue : null;
|
||||
return (
|
||||
<ControlledSelect
|
||||
<Select
|
||||
disabled={disabled}
|
||||
name={inputName}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
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]' },
|
||||
onValueChange={(newValue: string) => {
|
||||
setValue(inputName, newValue, { shouldDirty: true });
|
||||
}}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
<Option value="true">
|
||||
<ReadOnlyToggle
|
||||
checked
|
||||
slotProps={{ label: { className: '!text-sm' } }}
|
||||
/>
|
||||
</Option>
|
||||
|
||||
<Option value="false">
|
||||
<ReadOnlyToggle
|
||||
checked={false}
|
||||
slotProps={{ label: { className: '!text-sm' } }}
|
||||
/>
|
||||
</Option>
|
||||
</ControlledSelect>
|
||||
<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">
|
||||
<SelectValue placeholder="Is null?" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<span className="font-medium">true</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="false">
|
||||
<span className="font-medium">false</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
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') {
|
||||
const defaultValue = Array.isArray(field.value) ? field.value : [];
|
||||
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
disabled={disabled}
|
||||
name={inputName}
|
||||
multiple
|
||||
freeSolo
|
||||
limitTags={3}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'lg:!rounded-none !z-10',
|
||||
sx: sharedInputSx,
|
||||
},
|
||||
paper: { className: 'hidden' },
|
||||
<FancyMultiSelect
|
||||
className={className}
|
||||
options={availableHasuraPermissionVariables}
|
||||
creatable
|
||||
defaultValue={defaultValue.map((v) => ({ value: v, label: v }))}
|
||||
onChange={(value) => {
|
||||
setValue(
|
||||
inputName,
|
||||
value.map((v) => v.value),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[]}
|
||||
fullWidth
|
||||
filterSelectedOptions
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -194,71 +181,70 @@ export default function RuleValueInput({
|
||||
schema={schema}
|
||||
table={table}
|
||||
name={inputName}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
value: `X-Hasura-${key}`,
|
||||
label: `X-Hasura-${key}`,
|
||||
group: 'Frequently used',
|
||||
}));
|
||||
const selectedVariable = availableHasuraPermissionVariables.find(
|
||||
(variable) => variable.value === comboboxValue,
|
||||
);
|
||||
const comboboxLabel =
|
||||
selectedVariable?.label || comboboxValue || 'Select variable...';
|
||||
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
disabled={disabled}
|
||||
freeSolo={!isHasuraInput}
|
||||
autoHighlight={isHasuraInput}
|
||||
isOptionEqualToValue={(
|
||||
option,
|
||||
value: string | number | AutocompleteOption<string>,
|
||||
) => {
|
||||
if (typeof value !== 'object') {
|
||||
return option.value.toLowerCase() === value?.toString().toLowerCase();
|
||||
}
|
||||
|
||||
return option.value.toLowerCase() === value.value.toLowerCase();
|
||||
}}
|
||||
name={inputName}
|
||||
groupBy={(option) => option.group}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'lg:!rounded-none',
|
||||
sx: sharedInputSx,
|
||||
},
|
||||
formControl: { className: '!bg-transparent' },
|
||||
paper: { className: 'empty:border-transparent' },
|
||||
}}
|
||||
fullWidth
|
||||
loading={loading}
|
||||
loadingText={<ActivityIndicator label="Loading..." />}
|
||||
error={Boolean(customClaimsError) || error}
|
||||
helperText={customClaimsError?.message || helperText}
|
||||
options={
|
||||
isHasuraInput
|
||||
? availableHasuraPermissionVariables
|
||||
: [
|
||||
{
|
||||
value: 'X-Hasura-User-Id',
|
||||
label: 'X-Hasura-User-Id',
|
||||
group: 'Frequently used',
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange={(_event, _value, reason, details) => {
|
||||
if (
|
||||
reason !== 'selectOption' &&
|
||||
details.option.value !== 'X-Hasura-User-Id'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(inputName, details.option.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="truncate">{comboboxLabel}</span>
|
||||
<ChevronsUpDown className="h-5 min-h-5 w-5 min-w-5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Choose variable..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No variable found.</CommandEmpty>
|
||||
{loading && <CommandLoading>Loading...</CommandLoading>}
|
||||
<CommandGroup>
|
||||
{availableHasuraPermissionVariables.map((variable) => (
|
||||
<CommandItem
|
||||
key={variable.value}
|
||||
value={variable.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(inputName, currentValue, { shouldDirty: true });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{variable.label}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
comboboxValue === variable.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandCreateItem
|
||||
onCreate={(currentValue) => {
|
||||
setValue(inputName, currentValue, { shouldDirty: true });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</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 { generateAppServiceUrl } from '@/features/projects/common/utils/generateAppServiceUrl';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
@@ -39,10 +39,12 @@ export default function useUpdateColumnMutation({
|
||||
const {
|
||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = useRouter();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
'hasura',
|
||||
);
|
||||
const mutationFn = isPlatform ? updateColumn : updateColumnMigration;
|
||||
@@ -55,7 +57,7 @@ export default function useUpdateColumnMutation({
|
||||
adminSecret:
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: customAdminSecret || currentProject?.config?.hasura.adminSecret,
|
||||
: customAdminSecret || project?.config?.hasura.adminSecret,
|
||||
dataSource: customDataSource || (dataSourceSlug as string),
|
||||
schema: customSchema || (schemaSlug 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 { getHasuraAdminSecret } from '@/utils/env';
|
||||
import type { MutationOptions } from '@tanstack/react-query';
|
||||
@@ -40,10 +40,12 @@ export default function useUpdateRecordMutation<TData extends object = {}>({
|
||||
const {
|
||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = useRouter();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const { project } = useProject();
|
||||
|
||||
const appUrl = generateAppServiceUrl(
|
||||
currentProject?.subdomain,
|
||||
currentProject?.region,
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
'hasura',
|
||||
);
|
||||
|
||||
@@ -55,7 +57,7 @@ export default function useUpdateRecordMutation<TData extends object = {}>({
|
||||
adminSecret:
|
||||
process.env.NEXT_PUBLIC_ENV === 'dev'
|
||||
? getHasuraAdminSecret()
|
||||
: customAdminSecret || currentProject?.config?.hasura.adminSecret,
|
||||
: customAdminSecret || project?.config?.hasura.adminSecret,
|
||||
dataSource: customDataSource || (dataSourceSlug as string),
|
||||
schema: customSchema || (schemaSlug as string),
|
||||
table: customTable || (tableSlug as string),
|
||||
|
||||
@@ -544,9 +544,7 @@ export type HasuraOperator =
|
||||
| '_eq'
|
||||
| '_neq'
|
||||
| '_in'
|
||||
| '_in_hasura'
|
||||
| '_nin'
|
||||
| '_nin_hasura'
|
||||
| '_gt'
|
||||
| '_lt'
|
||||
| '_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', () => {
|
||||
expect(
|
||||
convertToRuleGroup({ _not: { title: { _eq: 'test' } } }),
|
||||
|
||||
@@ -52,8 +52,6 @@ const negatedValueOperatorPairs: Record<HasuraOperator, HasuraOperator> = {
|
||||
_cgte: '_clt',
|
||||
_clte: '_cgt',
|
||||
_is_null: '_is_null',
|
||||
_in_hasura: '_nin_hasura',
|
||||
_nin_hasura: '_in_hasura',
|
||||
};
|
||||
|
||||
export default function convertToRuleGroup(
|
||||
@@ -151,16 +149,14 @@ export default function convertToRuleGroup(
|
||||
(currentKey === '_in' || currentKey === '_nin') &&
|
||||
typeof value === 'string'
|
||||
) {
|
||||
const operator = currentKey === '_in' ? '_in_hasura' : '_nin_hasura';
|
||||
|
||||
return {
|
||||
operator: '_and',
|
||||
rules: [
|
||||
{
|
||||
column: previousKey,
|
||||
operator: shouldNegate
|
||||
? negatedValueOperatorPairs[operator]
|
||||
: operator,
|
||||
? negatedValueOperatorPairs[currentKey]
|
||||
: currentKey,
|
||||
value,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,20 +19,23 @@ export const POSTGRESQL_ERROR_CODES = {
|
||||
*
|
||||
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
|
||||
*/
|
||||
export const POSTGRESQL_NUMERIC_TYPES = [
|
||||
export const POSTGRESQL_INTEGER_TYPES = [
|
||||
'smallint',
|
||||
'integer',
|
||||
'bigint',
|
||||
'decimal',
|
||||
'numeric',
|
||||
'real',
|
||||
'double precision',
|
||||
'smallserial',
|
||||
'serial',
|
||||
'bigserial',
|
||||
'oid',
|
||||
];
|
||||
|
||||
export const POSTGRESQL_DECIMAL_TYPES = [
|
||||
'decimal',
|
||||
'numeric',
|
||||
'real',
|
||||
'double precision',
|
||||
];
|
||||
|
||||
/**
|
||||
* Character data types in PostgreSQL.
|
||||
*
|
||||
|
||||
@@ -118,8 +118,8 @@ export default function DatabaseConnectionInfo() {
|
||||
}
|
||||
|
||||
const postgresHost = generateAppServiceUrl(
|
||||
project.subdomain,
|
||||
project.region,
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
'db',
|
||||
).replace('https://', '');
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ const validationSchema = Yup.object({
|
||||
value: Yup.string().required('Major version is a required field'),
|
||||
})
|
||||
.label('Postgres major version')
|
||||
.required(),
|
||||
.required()
|
||||
.test('not-zero', 'Invalid major version', (value) => value?.value !== '0'),
|
||||
minorVersion: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required('Minor version is a required field'),
|
||||
@@ -186,18 +187,29 @@ export default function DatabaseServiceVersionSettings() {
|
||||
shouldPoll: true,
|
||||
});
|
||||
|
||||
const showMigrateWarning =
|
||||
Number(selectedMajor) > Number(currentPostgresMajor);
|
||||
|
||||
const { state } = useAppState();
|
||||
const applicationUpdating =
|
||||
state === ApplicationStatus.Updating ||
|
||||
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 =
|
||||
state !== ApplicationStatus.Live && !applicationUpdating;
|
||||
!applicationLive &&
|
||||
!applicationPaused &&
|
||||
!applicationPausing &&
|
||||
!applicationUpdating;
|
||||
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
||||
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
||||
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
||||
|
||||
const versionFieldsDisabled =
|
||||
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
||||
const saveDisabled = versionFieldsDisabled || !isDirty;
|
||||
@@ -208,7 +220,7 @@ export default function DatabaseServiceVersionSettings() {
|
||||
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
||||
|
||||
// Major version change
|
||||
if (isMajorVersionDirty) {
|
||||
if (isMajorVersionDirty && applicationLive) {
|
||||
openDialog({
|
||||
title: 'Update Postgres MAJOR version',
|
||||
component: (
|
||||
@@ -228,7 +240,7 @@ export default function DatabaseServiceVersionSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Minor version change
|
||||
// Only minor version change or project is paused/pausing
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: project.id,
|
||||
@@ -338,7 +350,6 @@ export default function DatabaseServiceVersionSettings() {
|
||||
return option.value;
|
||||
}}
|
||||
showCustomOption="auto"
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
@@ -383,12 +394,13 @@ export default function DatabaseServiceVersionSettings() {
|
||||
form.setValue('majorVersion', value);
|
||||
}
|
||||
}}
|
||||
clearOnBlur
|
||||
fullWidth
|
||||
className="lg:col-span-1"
|
||||
label="MAJOR"
|
||||
options={availableMajorVersions}
|
||||
error={!!formState.errors?.majorVersion?.value?.message}
|
||||
helperText={formState.errors?.majorVersion?.value?.message}
|
||||
error={!!formState.errors?.majorVersion?.message}
|
||||
helperText={formState.errors?.majorVersion?.message}
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
@@ -424,12 +436,13 @@ export default function DatabaseServiceVersionSettings() {
|
||||
|
||||
return result;
|
||||
}}
|
||||
clearOnBlur
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
label="MINOR"
|
||||
options={availableMinorVersions}
|
||||
error={!!formState.errors?.minorVersion?.value?.message}
|
||||
helperText={formState.errors?.minorVersion?.value?.message}
|
||||
error={!!formState.errors?.minorVersion?.message}
|
||||
helperText={formState.errors?.minorVersion?.message}
|
||||
showCustomOption="auto"
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
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 { DatabaseStorageCapacityWarning } from '@/features/orgs/projects/database/settings/components/DatabaseStorageCapacityWarning';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
@@ -15,18 +17,25 @@ import {
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
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 { org } = useCurrentOrg();
|
||||
const { maintenanceActive } = useUI();
|
||||
@@ -58,8 +67,32 @@ export default function AuthDomain() {
|
||||
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 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(() => {
|
||||
if (data && !loading) {
|
||||
@@ -81,7 +114,7 @@ export default function AuthDomain() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
||||
async function handleSubmit(formValues: DatabaseStorageCapacityFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfig({
|
||||
@@ -120,7 +153,7 @@ export default function AuthDomain() {
|
||||
description="Specify the storage capacity for your PostgreSQL database."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
disabled: submitDisabled,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
@@ -134,25 +167,25 @@ export default function AuthDomain() {
|
||||
{...register('capacity')}
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
type="text"
|
||||
endAdornment={
|
||||
<InputAdornment className="absolute right-2" position="end">
|
||||
GB
|
||||
</InputAdornment>
|
||||
}
|
||||
fullWidth
|
||||
disabled={project.legacyPlan?.isFree}
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.capacity?.message)}
|
||||
helperText={formState.errors.capacity?.message}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: capacity,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!project.legacyPlan?.isFree && (
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
Note that volumes can only be increased (not decreased). Also, due
|
||||
to an AWS limitation, the same volume can only be increased once
|
||||
every 6 hours.
|
||||
</Alert>
|
||||
<DatabaseStorageCapacityWarning
|
||||
state={state}
|
||||
decreasingSize={decreasingSize}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</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', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={defaultDeployment}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
render(<DeploymentStatusMessage deployment={defaultDeployment} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('img', {
|
||||
name: `Avatar of ${defaultDeployment.commitUserName}`,
|
||||
}),
|
||||
).toHaveAttribute(
|
||||
'style',
|
||||
`background-image: url(${defaultDeployment.commitUserAvatarUrl});`,
|
||||
);
|
||||
).toHaveAttribute('src', `${defaultDeployment.commitUserAvatarUrl}`);
|
||||
});
|
||||
|
||||
test('should render "updated just now" when the deployment is in progress and has not ended', () => {
|
||||
render(
|
||||
<DeploymentStatusMessage
|
||||
deployment={defaultDeployment}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
render(<DeploymentStatusMessage deployment={defaultDeployment} />);
|
||||
|
||||
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',
|
||||
deploymentEndedAt: null,
|
||||
}}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -76,19 +62,8 @@ test('should render "deployed 1 day ago" when the deployment has ended', () => {
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
deploymentEndedAt: '2023-02-24T12:15:00.000Z',
|
||||
}}
|
||||
appCreatedAt="2023-02-24"
|
||||
/>,
|
||||
);
|
||||
|
||||
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 type { Deployment } from '@/types/application';
|
||||
import formatDistance from 'date-fns/formatDistance';
|
||||
|
||||
export interface DeploymentStatusMessageProps {
|
||||
/**
|
||||
* The deployment to render the status message for.
|
||||
*/
|
||||
deployment: Partial<Deployment>;
|
||||
/**
|
||||
* The date the application was created.
|
||||
*/
|
||||
appCreatedAt: string;
|
||||
}
|
||||
|
||||
export default function DeploymentStatusMessage({
|
||||
deployment,
|
||||
appCreatedAt,
|
||||
}: DeploymentStatusMessageProps) {
|
||||
const isDeployingToProduction = [
|
||||
'SCHEDULED',
|
||||
@@ -29,11 +21,10 @@ export default function DeploymentStatusMessage({
|
||||
(deployment && !deployment.deploymentEndedAt)
|
||||
) {
|
||||
return (
|
||||
<span className="flex flex-row">
|
||||
<span className="flex flex-row justify-start">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
alt={`Avatar of ${deployment.commitUserName}`}
|
||||
src={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
/>
|
||||
<Text component="span" className="self-center text-sm">
|
||||
@@ -44,30 +35,26 @@ export default function DeploymentStatusMessage({
|
||||
}
|
||||
|
||||
if (!isDeployingToProduction && deployment?.deploymentEndedAt) {
|
||||
const statusMessage = `deployed ${formatDistance(new Date(deployment.deploymentEndedAt), new Date(), { addSuffix: true })}`;
|
||||
|
||||
return (
|
||||
<span className="grid grid-flow-col">
|
||||
<div className="relative flex flex-row">
|
||||
<Avatar
|
||||
component="span"
|
||||
name={deployment.commitUserName}
|
||||
avatarUrl={deployment.commitUserAvatarUrl}
|
||||
className="mr-1 h-4 w-4 self-center"
|
||||
alt={`Avatar of ${deployment.commitUserName}`}
|
||||
src={deployment.commitUserAvatarUrl}
|
||||
className="mr-2 mt-1 h-4 w-4"
|
||||
/>
|
||||
<Text component="span" className="self-center truncate text-sm">
|
||||
{deployment.commitUserName} deployed{' '}
|
||||
{formatDistance(new Date(deployment.deploymentEndedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</Text>
|
||||
</span>
|
||||
<div className="flex flex-col text-sm text-muted-foreground">
|
||||
<p className="line-clamp-1 break-all">{deployment.commitUserName}</p>
|
||||
<p>{statusMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text component="span" className="text-sm">
|
||||
created{' '}
|
||||
{formatDistance(new Date(appCreatedAt), new Date(), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
<Text component="span" className="text-sm text-muted-foreground">
|
||||
No deployments
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function useGetAppUsers({
|
||||
offset = 0,
|
||||
options = {},
|
||||
}: UseFilesOptions) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||
const { data, error, loading } = useRemoteAppGetUsersCustomQuery({
|
||||
...options,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function useAppClient(
|
||||
options?: UseAppClientOptions,
|
||||
): UseAppClientReturn {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
|
||||
if (!isPlatform) {
|
||||
return new NhostClient({
|
||||
|
||||
@@ -2,21 +2,16 @@ import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
GetProjectDocument,
|
||||
useGetProjectQuery,
|
||||
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];
|
||||
|
||||
interface UseProjectOptions {
|
||||
poll?: boolean;
|
||||
target?: 'console-next' | 'user-project';
|
||||
}
|
||||
|
||||
export interface UseProjectReturnType {
|
||||
project: Project;
|
||||
loading?: boolean;
|
||||
@@ -24,10 +19,7 @@ export interface UseProjectReturnType {
|
||||
refetch: (variables?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function useProject({
|
||||
poll = false,
|
||||
target = 'console-next',
|
||||
}: UseProjectOptions = {}): UseProjectReturnType {
|
||||
export default function useProject(): UseProjectReturnType {
|
||||
const {
|
||||
query: { appSubdomain },
|
||||
isReady: isRouterReady,
|
||||
@@ -37,65 +29,36 @@ export default function useProject({
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
|
||||
const shouldFetchProject =
|
||||
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],
|
||||
const shouldFetchProject = useMemo(
|
||||
() =>
|
||||
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) || '',
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: shouldFetchProject && target === 'user-project',
|
||||
staleTime: poll ? 5000 : Infinity, // Adjust staleTime for better performance
|
||||
enabled: shouldFetchProject,
|
||||
},
|
||||
);
|
||||
|
||||
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) {
|
||||
return {
|
||||
project,
|
||||
loading,
|
||||
error,
|
||||
project: data?.data?.apps?.[0] || null,
|
||||
loading: isLoading && shouldFetchProject,
|
||||
error: Array.isArray(error || {}) ? error[0] : error,
|
||||
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(),
|
||||
user: yup.string().label('Username').required(),
|
||||
password: yup.string().label('Password'),
|
||||
sender: yup.string().label('SMTP Sender').email().required(),
|
||||
sender: yup.string().label('SMTP Sender').required(),
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
@@ -1,54 +1,132 @@
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { MetricsCardProps } from '@/features/orgs/projects/overview/components/MetricsCard';
|
||||
import { MetricsCard } from '@/features/orgs/projects/overview/components/MetricsCard';
|
||||
import { prettifyNumber } from '@/utils/prettifyNumber';
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { useGetProjectMetricsQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetProjectMetricsQuery,
|
||||
useGetProjectRequestsMetricQuery,
|
||||
useGetUserProjectMetricsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { formatISO, startOfDay, startOfMonth, subMinutes } from 'date-fns';
|
||||
|
||||
const now = new Date();
|
||||
|
||||
export default function OverviewMetrics() {
|
||||
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: {
|
||||
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,
|
||||
from: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||
},
|
||||
skip: !project?.id,
|
||||
skip: !project,
|
||||
});
|
||||
|
||||
const cardElements: MetricsCardProps[] = [
|
||||
{
|
||||
label: 'CPU Usage Seconds',
|
||||
tooltip: 'Total time the service has used the CPUs',
|
||||
value: prettifyNumber(data?.cpuSecondsUsage?.value || 0),
|
||||
label: 'Daily Active Users',
|
||||
tooltip: 'Unique users active today',
|
||||
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',
|
||||
tooltip:
|
||||
'Total amount of requests your services have received excluding functions',
|
||||
value: prettifyNumber(data?.totalRequests?.value || 0, {
|
||||
numberOfDecimals: data?.totalRequests?.value > 1000 ? 2 : 0,
|
||||
tooltip: 'Total service requests this month so far (excluding functions)',
|
||||
value: prettifyNumber(totalRequests || 0, {
|
||||
numberOfDecimals: totalRequests > 1000 ? 2 : 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Function Invocations',
|
||||
tooltip: 'Number of times your functions have been called',
|
||||
value: prettifyNumber(data?.functionInvocations?.value || 0, {
|
||||
numberOfDecimals: 0,
|
||||
}),
|
||||
label: 'Egress',
|
||||
tooltip: 'Total outgoing data transfer this month so far',
|
||||
value: prettifySize(egressVolume),
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
tooltip: 'Amount of logs stored',
|
||||
value: prettifySize(data?.logsVolume?.value || 0),
|
||||
label: 'Functions Duration',
|
||||
tooltip: 'Total Functions execution this month so far',
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,19 @@ import { calculateBillableResources } from '@/features/orgs/projects/resources/s
|
||||
import type { ResourceSettingsFormValues } 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 {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/constants/common';
|
||||
import type { GetResourcesQuery } from '@/utils/__generated__/graphql';
|
||||
import type {
|
||||
ConfigConfigUpdateInput,
|
||||
GetResourcesQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetResourcesQuery,
|
||||
useUpdateConfigMutation,
|
||||
} 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 { FormProvider, useForm } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -36,7 +40,7 @@ function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { compute, replicas, autoscaler } =
|
||||
const { compute, replicas, autoscaler, ...rest } =
|
||||
data?.config?.[service]?.resources || {};
|
||||
|
||||
return {
|
||||
@@ -44,6 +48,7 @@ function getInitialServiceResources(
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
autoscale: autoscaler || null,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,76 +181,130 @@ export default function ResourcesForm() {
|
||||
? (billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE
|
||||
: 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) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
config: {
|
||||
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,
|
||||
},
|
||||
},
|
||||
config: getFormattedConfig(formValues),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function ServiceResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
Allocated vCPUs:{' '}
|
||||
<span className="font-medium">
|
||||
@@ -201,7 +201,7 @@ export default function ServiceResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
Allocated Memory:{' '}
|
||||
<span className="font-medium">
|
||||
@@ -246,7 +246,7 @@ export default function ServiceResourcesFormFragment({
|
||||
>
|
||||
<ExclamationIcon
|
||||
color="error"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -274,7 +274,7 @@ export default function ServiceResourcesFormFragment({
|
||||
>
|
||||
<ExclamationIcon
|
||||
color="error"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -306,7 +306,7 @@ export default function ServiceResourcesFormFragment({
|
||||
<Tooltip
|
||||
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>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -323,7 +323,7 @@ export default function ServiceResourcesFormFragment({
|
||||
className="font-medium"
|
||||
>
|
||||
Service Replicas
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,11 @@ fragment ServiceResources on ConfigConfig {
|
||||
autoscaler {
|
||||
maxReplicas
|
||||
}
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
@@ -21,10 +26,19 @@ fragment ServiceResources on ConfigConfig {
|
||||
autoscaler {
|
||||
maxReplicas
|
||||
}
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
resources {
|
||||
storage {
|
||||
capacity
|
||||
}
|
||||
enablePublicAccess
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
|
||||
@@ -19,7 +19,6 @@ import { StorageFormSection } from '@/features/orgs/projects/services/components
|
||||
import { useHostName } from '@/features/projects/common/hooks/useHostName';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
validationSchema,
|
||||
@@ -29,16 +28,15 @@ import {
|
||||
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -58,9 +56,10 @@ export default function ServiceForm({
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { project } = useProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
@@ -96,14 +95,14 @@ export default function ServiceForm({
|
||||
if (serviceID) {
|
||||
return serviceID;
|
||||
}
|
||||
return uuidv4();
|
||||
return '<uuid-to-be-generated-on-creation>';
|
||||
}, [serviceID]);
|
||||
|
||||
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
||||
|
||||
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
||||
|
||||
if (initialData?.image?.startsWith(privateRegistryImage)) {
|
||||
if (initialData?.image?.startsWith(privateRegistryImage.split('/')[0])) {
|
||||
initialImageType = 'nhost';
|
||||
}
|
||||
|
||||
@@ -225,33 +224,14 @@ export default function ServiceForm({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
object: {
|
||||
appID: project.id,
|
||||
id: newServiceID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create service
|
||||
await insertRunServiceConfig({
|
||||
variables: {
|
||||
appID: project.id,
|
||||
serviceID: id,
|
||||
config: {
|
||||
...config,
|
||||
image: {
|
||||
// If the image field left empty then we auto-populate following this format
|
||||
// registry.<region>.<nhost_domain>/<service_id>
|
||||
image:
|
||||
values.image.length > 0
|
||||
? values.image
|
||||
: `registry.${project.region.name}.${project.region.domain}/${newServiceID}`,
|
||||
image: values.image,
|
||||
pullCredentials:
|
||||
values.pullCredentials?.length > 0
|
||||
? values.pullCredentials
|
||||
@@ -335,7 +315,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</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.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -414,7 +394,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
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">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
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(),
|
||||
command: Yup.string(),
|
||||
environment: Yup.array().of(
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ReplicasFormSection() {
|
||||
};
|
||||
|
||||
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">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Replicas ({replicas})
|
||||
@@ -65,7 +65,7 @@ export default function ReplicasFormSection() {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function ReplicasFormSection() {
|
||||
/>
|
||||
<Text>Autoscaler</Text>
|
||||
<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>
|
||||
</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 type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
|
||||
export type DataGridNumericCellProps<TData extends object> =
|
||||
export type DataGridIntegerCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, number>;
|
||||
|
||||
export default function DataGridNumericCell<TData extends object>({
|
||||
export default function DataGridIntegerCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
}: DataGridNumericCellProps<TData>) {
|
||||
}: DataGridIntegerCellProps<TData>) {
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
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 },
|
||||
fallbackPreview = null,
|
||||
}: DataGridPreviewCellProps<TData>) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
||||
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 { useFiles } from '@/features/orgs/projects/storage/dataGrid/hooks/useFiles';
|
||||
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 { 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 { useRouter } from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
@@ -32,7 +32,7 @@ export type FilesDataGridProps = Partial<DataGridProps<StoredFile>>;
|
||||
|
||||
export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
const router = useRouter();
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const [searchString, setSearchString] = useState<string | null>(null);
|
||||
const [currentOffset, setCurrentOffset] = useState<number | null>(
|
||||
@@ -118,7 +118,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
DataGridPreviewCell({
|
||||
...cellProps,
|
||||
fallbackPreview: (
|
||||
<FilePreviewIcon className="w-5 h-5 fill-current" />
|
||||
<FilePreviewIcon className="h-5 w-5 fill-current" />
|
||||
),
|
||||
}),
|
||||
minWidth: 80,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { FileUploadButtonProps } 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 { triggerToast } from '@/utils/toast';
|
||||
import type { Files } from '@/utils/__generated__/graphql';
|
||||
import type { PropsWithoutRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { Row } from 'react-table';
|
||||
@@ -38,7 +38,7 @@ export default function FilesDataGridControls({
|
||||
...props
|
||||
}: FilesDataGridControlsProps) {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function FilesDataGridControls({
|
||||
</Button>
|
||||
</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
|
||||
className={twMerge(
|
||||
'col-span-12 xs+:col-span-12 md:col-span-9 xl:col-span-10',
|
||||
@@ -170,7 +170,7 @@ export default function FilesDataGridControls({
|
||||
{...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
|
||||
className={twMerge('col-span-6', paginationClassName)}
|
||||
{...restPaginationProps}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import type {
|
||||
Files_Order_By as FilesOrderBy,
|
||||
GetFilesQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useGetFilesQuery } from '@/utils/__generated__/graphql';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
|
||||
export type UseFilesOptions = {
|
||||
@@ -38,7 +38,7 @@ export default function useFiles({
|
||||
orderBy,
|
||||
options = {},
|
||||
}: UseFilesOptions) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const { data, previousData, ...rest } = useGetFilesQuery({
|
||||
variables: {
|
||||
where: searchString
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function useNotFoundRedirect() {
|
||||
router.pathname === '/account' ||
|
||||
router.pathname === '/support/ticket' ||
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
router.pathname.includes('/orgs/_') ||
|
||||
router.pathname.includes('/orgs/_/projects/_') ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
|
||||
@@ -28,16 +28,15 @@ import {
|
||||
type ServiceFormValues,
|
||||
} from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -57,7 +56,6 @@ export default function ServiceForm({
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||
@@ -187,20 +185,11 @@ export default function ServiceForm({
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID, subdomain },
|
||||
insertRunServiceConfig: { serviceID: newServiceID },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
object: {
|
||||
appID: currentProject.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await insertRunServiceConfig({
|
||||
} = await insertRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID: newServiceID,
|
||||
config: {
|
||||
...config,
|
||||
image: {
|
||||
@@ -209,14 +198,14 @@ export default function ServiceForm({
|
||||
image:
|
||||
values.image.length > 0
|
||||
? 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);
|
||||
setDetailsServiceSubdomain(subdomain);
|
||||
setDetailsServiceSubdomain('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,7 +311,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -362,7 +351,7 @@ export default function ServiceForm({
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</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.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -441,7 +430,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
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">
|
||||
<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