Compare commits
33 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
60b685ab02 | ||
|
|
2e65bc6dc0 | ||
|
|
14e6100722 | ||
|
|
479dba102e |
@@ -14,7 +14,7 @@ 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
|
||||
@@ -26,10 +26,10 @@ runs:
|
||||
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 }}
|
||||
1
.github/workflows/gen_ai_review.yaml
vendored
1
.github/workflows/gen_ai_review.yaml
vendored
@@ -12,7 +12,6 @@ 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
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<a href="https://twitter.com/nhost">Twitter</a>
|
||||
<span> • </span>
|
||||
<a href="https://nhost.io/discord">Discord</a>
|
||||
<span> • </span>
|
||||
<a href="https://gurubase.io/g/nhost">Ask Nhost Guru (third party, unofficial)</a>
|
||||
<br />
|
||||
|
||||
<hr />
|
||||
@@ -148,4 +150,4 @@ Here are some ways of contributing to making Nhost better:
|
||||
<p align="center">
|
||||
<img width="720" src="https://contrib.rocks/image?repo=nhost/nhost" alt="A table of avatars from the project's contributors" />
|
||||
</p>
|
||||
</a>
|
||||
</a>
|
||||
@@ -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,46 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 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.1",
|
||||
"version": "2.12.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",
|
||||
|
||||
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 },
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
9
dashboard/src/gql/app/getProjectRequestsMetric.gql
Normal file
9
dashboard/src/gql/app/getProjectRequestsMetric.gql
Normal file
@@ -0,0 +1,9 @@
|
||||
query GetProjectRequestsMetric(
|
||||
$appId: String!
|
||||
$from: Timestamp
|
||||
$to: Timestamp
|
||||
) {
|
||||
totalRequests: getTotalRequests(appID: $appId, from: $from, to: $to) {
|
||||
value
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
query getAssistants {
|
||||
query getAssistants($isFileStoresSupported: Boolean!) {
|
||||
graphite {
|
||||
assistants {
|
||||
assistantID
|
||||
@@ -28,6 +28,7 @@ query getAssistants {
|
||||
required
|
||||
}
|
||||
}
|
||||
fileStores @include(if: $isFileStoresSupported)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
mutation deleteFileStore($id: uuid!) {
|
||||
graphite {
|
||||
deleteFileStore(id: $id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
query getGraphiteFileStores {
|
||||
graphite {
|
||||
fileStores {
|
||||
id
|
||||
name
|
||||
vectorStoreID
|
||||
buckets
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation insertFileStore($object: graphiteFileStoreInput!) {
|
||||
graphite {
|
||||
insertFileStore(object: $object) {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation updateFileStore($id: uuid!, $object: graphiteFileStoreInput!) {
|
||||
graphite {
|
||||
updateFileStore(id: $id, object: $object) {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
23
dashboard/src/gql/organizations/getProjectState.gql
Normal file
23
dashboard/src/gql/organizations/getProjectState.gql
Normal file
@@ -0,0 +1,23 @@
|
||||
query getProjectState($subdomain: String!) {
|
||||
apps(where: { subdomain: { _eq: $subdomain } }) {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
region {
|
||||
id
|
||||
countryCode
|
||||
name
|
||||
domain
|
||||
city
|
||||
}
|
||||
createdAt
|
||||
desiredState
|
||||
appStates(order_by: { createdAt: desc }, limit: 1) {
|
||||
id
|
||||
appId
|
||||
message
|
||||
stateId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@ query getProjects($orgSlug: String!) {
|
||||
slug
|
||||
createdAt
|
||||
subdomain
|
||||
region {
|
||||
id
|
||||
name
|
||||
}
|
||||
deployments(limit: 4, order_by: { deploymentStartedAt: desc }) {
|
||||
id
|
||||
commitSHA
|
||||
@@ -20,5 +24,12 @@ query getProjects($orgSlug: String!) {
|
||||
email
|
||||
displayName
|
||||
}
|
||||
appStates(order_by: { createdAt: desc }, limit: 1) {
|
||||
id
|
||||
appId
|
||||
message
|
||||
stateId
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
dashboard/src/gql/organizations/getUserProjectMetrics.gql
Normal file
28
dashboard/src/gql/organizations/getUserProjectMetrics.gql
Normal file
@@ -0,0 +1,28 @@
|
||||
query GetUserProjectMetrics($startOfMonth: timestamptz!, $today: timestamptz!) {
|
||||
monthlyActiveUsers: usersAggregate(
|
||||
where: { lastSeen: { _gte: $startOfMonth, _lte: $today } }
|
||||
) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
dailyActiveUsers: usersAggregate(where: { lastSeen: { _gte: $today } }) {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
allUsers: usersAggregate {
|
||||
aggregate {
|
||||
count
|
||||
}
|
||||
}
|
||||
|
||||
filesAggregate {
|
||||
aggregate {
|
||||
count
|
||||
sum {
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { useIsGraphiteEnabled } from '@/features/projects/common/hooks/useIsGrap
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
useGetAssistantsQuery,
|
||||
type GetAssistantsQuery,
|
||||
type GetAssistantsQuery
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
@@ -29,21 +29,29 @@ export type Assistant = Omit<
|
||||
export default function AssistantsPage() {
|
||||
const { openDrawer } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
const { isGraphiteEnabled } = useIsGraphiteEnabled();
|
||||
|
||||
const { data, loading, refetch } = useGetAssistantsQuery({
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
refetch,
|
||||
} = useGetAssistantsQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const assistants = useMemo(() => data?.graphite?.assistants || [], [data]);
|
||||
|
||||
const assistants = useMemo(
|
||||
() => data?.graphite?.assistants || [],
|
||||
[data],
|
||||
);
|
||||
|
||||
const openCreateAssistantForm = () => {
|
||||
openDrawer({
|
||||
title: 'Create a new Assistant',
|
||||
component: <AssistantForm onSubmit={refetch} />,
|
||||
component: (
|
||||
<AssistantForm onSubmit={refetch} />
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -97,7 +105,11 @@ export default function AssistantsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.graphite?.assistants.length === 0 && !loading) {
|
||||
if (loading) {
|
||||
return <Box className="p-4">Loading...</Box>;
|
||||
}
|
||||
|
||||
if (assistants.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
@@ -141,13 +153,11 @@ export default function AssistantsPage() {
|
||||
New
|
||||
</Button>
|
||||
</Box>
|
||||
<div>
|
||||
<AssistantsList
|
||||
assistants={assistants}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
<AssistantsList
|
||||
assistants={assistants}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -388,9 +388,5 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<ProjectLayout contentContainerProps={{ className: 'h-full' }}>
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
};
|
||||
|
||||
@@ -65,10 +65,7 @@ export default function IndexPage() {
|
||||
|
||||
IndexPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
title="Dashboard"
|
||||
contentContainerProps={{ className: 'flex w-full flex-col' }}
|
||||
>
|
||||
<AuthenticatedLayout title="Dashboard">
|
||||
<Container className="py-0">
|
||||
<MaintenanceAlert />
|
||||
</Container>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
import {
|
||||
useGetAssistantsQuery,
|
||||
useGetGraphiteFileStoresQuery,
|
||||
type GetAssistantsQuery,
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
@@ -21,6 +22,7 @@ import { AISidebar } from '@/features/orgs/layout/AISidebar';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { AssistantForm } from '@/features/orgs/projects/ai/AssistantForm';
|
||||
import { AssistantsList } from '@/features/orgs/projects/ai/AssistantsList';
|
||||
import { useIsFileStoreSupported } from '@/features/orgs/projects/common/hooks/useIsFileStoreSupported';
|
||||
import { useIsGraphiteEnabled } from '@/features/orgs/projects/common/hooks/useIsGraphiteEnabled';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
@@ -43,24 +45,46 @@ export default function AssistantsPage() {
|
||||
const { isGraphiteEnabled, loading: loadingGraphite } =
|
||||
useIsGraphiteEnabled();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading: loadingAssistants,
|
||||
refetch,
|
||||
} = useGetAssistantsQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
const { isFileStoreSupported, loading: fileStoreLoading } =
|
||||
useIsFileStoreSupported();
|
||||
|
||||
const assistants = useMemo(() => data?.graphite?.assistants || [], [data]);
|
||||
const {
|
||||
data: assistantsData,
|
||||
loading: assistantsLoading,
|
||||
refetch: assistantsRefetch,
|
||||
} = useGetAssistantsQuery({
|
||||
client: adminClient,
|
||||
variables: {
|
||||
isFileStoresSupported: isFileStoreSupported ?? false,
|
||||
},
|
||||
skip: isFileStoreSupported === null || fileStoreLoading,
|
||||
});
|
||||
const { data: fileStoresData } = useGetGraphiteFileStoresQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const assistants = useMemo(
|
||||
() => assistantsData?.graphite?.assistants || [],
|
||||
[assistantsData],
|
||||
);
|
||||
const fileStores = useMemo(
|
||||
() => fileStoresData?.graphite?.fileStores || [],
|
||||
[fileStoresData],
|
||||
);
|
||||
|
||||
const openCreateAssistantForm = () => {
|
||||
openDrawer({
|
||||
title: 'Create a new Assistant',
|
||||
component: <AssistantForm onSubmit={refetch} />,
|
||||
component: (
|
||||
<AssistantForm
|
||||
onSubmit={assistantsRefetch}
|
||||
fileStores={isFileStoreSupported ? fileStores : undefined}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingOrg || loadingProject || loadingGraphite || loadingAssistants) {
|
||||
if (loadingOrg || loadingProject || loadingGraphite || assistantsLoading) {
|
||||
return (
|
||||
<Box className="flex items-center justify-center w-full h-full">
|
||||
<ActivityIndicator
|
||||
@@ -114,7 +138,7 @@ export default function AssistantsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.graphite?.assistants.length === 0 && !loadingAssistants) {
|
||||
if (assistants.length === 0 && !assistantsLoading) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
@@ -161,8 +185,9 @@ export default function AssistantsPage() {
|
||||
<div>
|
||||
<AssistantsList
|
||||
assistants={assistants}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
fileStores={isFileStoreSupported ? fileStores : undefined}
|
||||
onDelete={() => assistantsRefetch()}
|
||||
onCreateOrUpdate={() => assistantsRefetch()}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
@@ -12,14 +12,13 @@ import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { AISidebar } from '@/features/orgs/layout/AISidebar';
|
||||
// import AILayout from '@/features/orgs/layout/AILayout/AILayout';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { AutoEmbeddingsForm } from '@/features/orgs/projects/ai/AutoEmbeddingsForm';
|
||||
import { AutoEmbeddingsList } from '@/features/orgs/projects/ai/AutoEmbeddingsList';
|
||||
import { useIsGraphiteEnabled } from '@/features/orgs/projects/common/hooks/useIsGraphiteEnabled';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import {
|
||||
useGetGraphiteAutoEmbeddingsConfigurationsQuery,
|
||||
@@ -36,9 +35,11 @@ export type AutoEmbeddingsConfiguration = Omit<
|
||||
export default function AutoEmbeddingsPage() {
|
||||
const limit = useRef(25);
|
||||
const router = useRouter();
|
||||
|
||||
const { openDrawer } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentOrg: org } = useOrgs();
|
||||
|
||||
const { org } = useCurrentOrg();
|
||||
const { project } = useProject();
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
@@ -101,7 +102,7 @@ export default function AutoEmbeddingsPage() {
|
||||
}
|
||||
|
||||
if (
|
||||
(isPlatform && !org?.plan?.isFree && !project.config?.ai) ||
|
||||
(isPlatform && !org?.plan?.isFree && !project?.config?.ai) ||
|
||||
!isGraphiteEnabled
|
||||
) {
|
||||
return (
|
||||
@@ -128,7 +129,7 @@ export default function AutoEmbeddingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.graphiteAutoEmbeddingsConfigurations.length === 0 && !loading) {
|
||||
if (autoEmbeddingsConfigurations.length === 0 && !loading) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { UpgradeToProBanner } from '@/components/common/UpgradeToProBanner';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { FileStoresIcon } from '@/components/ui/v2/icons/FileStoresIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { FileStoreForm } from '@/features/orgs/projects/ai/FileStoreForm';
|
||||
import { FileStoresList } from '@/features/orgs/projects/ai/FileStoresList';
|
||||
import { useIsFileStoreSupported } from '@/features/orgs/projects/common/hooks/useIsFileStoreSupported';
|
||||
import { useIsGraphiteEnabled } from '@/features/orgs/projects/common/hooks/useIsGraphiteEnabled';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAdminApolloClient } from '@/features/orgs/projects/hooks/useAdminApolloClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import {
|
||||
useGetGraphiteFileStoresQuery,
|
||||
type GetGraphiteFileStoresQuery
|
||||
} from '@/utils/__generated__/graphite.graphql';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { AISidebar } from '@/features/orgs/layout/AISidebar';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
|
||||
export type GraphiteFileStore = Omit<
|
||||
GetGraphiteFileStoresQuery['graphite']['fileStores'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
export default function FileStoresPage() {
|
||||
const { openDrawer } = useDialog();
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { org, loading: loadingOrg } = useCurrentOrg();
|
||||
const { project, loading: loadingProject } = useProject();
|
||||
|
||||
const { adminClient } = useAdminApolloClient();
|
||||
const { isGraphiteEnabled } = useIsGraphiteEnabled();
|
||||
const { isFileStoreSupported } = useIsFileStoreSupported();
|
||||
|
||||
const { data, loading, refetch } = useGetGraphiteFileStoresQuery({
|
||||
client: adminClient,
|
||||
});
|
||||
|
||||
const fileStores = useMemo(() => data?.graphite.fileStores || [], [data]);
|
||||
|
||||
const openCreateFileStoreForm = () => {
|
||||
openDrawer({
|
||||
title: 'Create a new File Store',
|
||||
component: <FileStoreForm onSubmit={refetch} />,
|
||||
});
|
||||
};
|
||||
|
||||
if (loadingOrg || loadingProject || loading) {
|
||||
return (
|
||||
<Box className="flex items-center justify-center w-full h-full">
|
||||
<ActivityIndicator
|
||||
delay={1000}
|
||||
label="Loading File Stores..."
|
||||
className="justify-center"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPlatform && org?.plan?.isFree) {
|
||||
return (
|
||||
<Box className="p-4" sx={{ backgroundColor: 'background.default' }}>
|
||||
<UpgradeToProBanner
|
||||
title="Upgrade to Nhost Pro."
|
||||
description={
|
||||
<Text>
|
||||
Graphite is an addon to the Pro plan. To unlock it, please upgrade
|
||||
to Pro first.
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(isPlatform &&
|
||||
!org?.plan?.isFree &&
|
||||
!project.config?.ai) ||
|
||||
!isGraphiteEnabled
|
||||
) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-4"
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Alert className="grid w-full grid-flow-col place-content-between items-center gap-2">
|
||||
<Text className="grid grid-flow-row justify-items-start gap-0.5">
|
||||
<Text component="span">
|
||||
To enable graphite, configure the service first in{' '}
|
||||
<Link
|
||||
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings/ai`}
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
AI Settings
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</Text>
|
||||
</Alert>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (fileStores.length === 0 && !loading) {
|
||||
return (
|
||||
<Box
|
||||
className="w-full p-6"
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 rounded-lg border px-48 py-12 shadow-sm">
|
||||
<FileStoresIcon className="h-10 w-10" />
|
||||
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No File Stores are configured
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
File Stores are used to share storage documents with your
|
||||
AI assistants.
|
||||
</Text>
|
||||
{!isFileStoreSupported && (
|
||||
<Box className="px-4 pb-4">
|
||||
<Alert className="mt-2 text-left">
|
||||
Please upgrade Graphite to its latest version in order to use
|
||||
file stores.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-row place-content-between rounded-lg">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openCreateFileStoreForm}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
disabled={!isFileStoreSupported}
|
||||
>
|
||||
Add a new File Store
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col w-full overflow-hidden">
|
||||
<Box className="flex flex-row place-content-end border-b-1 p-4">
|
||||
<Button
|
||||
variant="contained"
|
||||
color="primary"
|
||||
onClick={openCreateFileStoreForm}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
New
|
||||
</Button>
|
||||
</Box>
|
||||
<div>
|
||||
<FileStoresList
|
||||
fileStores={fileStores}
|
||||
onDelete={() => refetch()}
|
||||
onCreateOrUpdate={() => refetch()}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
FileStoresPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{ className: 'flex flex-row w-full h-full' }}
|
||||
>
|
||||
<AISidebar className="w-full max-w-sidebar" />
|
||||
<RetryableErrorBoundary>{page}</RetryableErrorBoundary>
|
||||
</ProjectLayout>
|
||||
);
|
||||
|
||||
};
|
||||
@@ -117,7 +117,7 @@ function GraphiQLHeader({ onUserChange, onRoleChange }: GraphiQLHeaderProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="grid items-end grid-flow-row gap-2 p-2 md:grid-flow-col md:justify-between">
|
||||
<header className="grid grid-flow-row items-end gap-2 p-2 md:grid-flow-col md:justify-between">
|
||||
<div className="grid grid-flow-row gap-2 md:grid-flow-col md:items-end">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-flow-col md:grid-cols-[initial]">
|
||||
<UserSelect
|
||||
@@ -250,7 +250,7 @@ function GraphiQLEditor({ onHeaderChange }: GraphiQLEditorProps) {
|
||||
}
|
||||
|
||||
export default function GraphQLPage() {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const [userHeaders, setUserHeaders] = useState<Record<string, any>>({});
|
||||
|
||||
if (!project?.subdomain || !project?.config?.hasura.adminSecret) {
|
||||
|
||||
@@ -224,16 +224,16 @@ export default function UsersPage() {
|
||||
if (loadingRemoteAppUsersQuery) {
|
||||
return (
|
||||
<Container
|
||||
className="flex flex-col h-full max-w-9xl"
|
||||
className="flex h-full max-w-9xl flex-col"
|
||||
rootClassName="h-full"
|
||||
>
|
||||
<div className="flex flex-row shrink-0 grow-0 place-content-between">
|
||||
<div className="flex shrink-0 grow-0 flex-row place-content-between">
|
||||
<Input
|
||||
className="rounded-sm"
|
||||
placeholder="Search users"
|
||||
startAdornment={
|
||||
<SearchIcon
|
||||
className="w-4 h-4 ml-2 -mr-1 shrink-0"
|
||||
className="-mr-1 ml-2 h-4 w-4 shrink-0"
|
||||
sx={{ color: 'text.disabled' }}
|
||||
/>
|
||||
}
|
||||
@@ -241,14 +241,14 @@ export default function UsersPage() {
|
||||
/>
|
||||
<Button
|
||||
onClick={openCreateUserDialog}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
size="small"
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center flex-auto overflow-hidden">
|
||||
<div className="flex flex-auto items-center justify-center overflow-hidden">
|
||||
<ActivityIndicator label="Loading users..." />
|
||||
</div>
|
||||
</Container>
|
||||
@@ -256,14 +256,14 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="mx-auto space-y-5 overflow-x-hidden max-w-9xl">
|
||||
<Container className="mx-auto max-w-9xl space-y-5 overflow-x-hidden">
|
||||
<div className="flex flex-row place-content-between">
|
||||
<Input
|
||||
className="rounded-sm"
|
||||
placeholder="Search users"
|
||||
startAdornment={
|
||||
<SearchIcon
|
||||
className="w-4 h-4 ml-2 -mr-1 shrink-0"
|
||||
className="-mr-1 ml-2 h-4 w-4 shrink-0"
|
||||
sx={{ color: 'text.disabled' }}
|
||||
/>
|
||||
}
|
||||
@@ -271,21 +271,21 @@ export default function UsersPage() {
|
||||
/>
|
||||
<Button
|
||||
onClick={openCreateUserDialog}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
size="small"
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
{usersCount === 0 ? (
|
||||
<Box className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border rounded-lg shadow-sm">
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 rounded-lg border px-48 py-12 shadow-sm">
|
||||
<UserIcon
|
||||
strokeWidth={1}
|
||||
className="w-10 h-10"
|
||||
className="h-10 w-10"
|
||||
sx={{ color: 'text.disabled' }}
|
||||
/>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="font-medium text-center" variant="h3">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
There are no users yet
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
@@ -298,34 +298,34 @@ export default function UsersPage() {
|
||||
color="primary"
|
||||
className="w-full"
|
||||
onClick={openCreateUserDialog}
|
||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
||||
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||
>
|
||||
Create User
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
) : (
|
||||
<div className="grid grid-flow-row gap-2 lg:w-9xl">
|
||||
<div className="grid w-full h-full grid-flow-row pb-4 overflow-hidden">
|
||||
<Box className="grid w-full p-2 border-b md:grid-cols-6">
|
||||
<div className="lg:w-9xl grid grid-flow-row gap-2">
|
||||
<div className="grid h-full w-full grid-flow-row overflow-hidden pb-4">
|
||||
<Box className="grid w-full border-b p-2 md:grid-cols-6">
|
||||
<Text className="font-medium md:col-span-2">Name</Text>
|
||||
<Text className="hidden font-medium md:block">Signed up at</Text>
|
||||
<Text className="hidden font-medium md:block">Last Seen</Text>
|
||||
<Text className="hidden col-span-2 font-medium md:block">
|
||||
<Text className="col-span-2 hidden font-medium md:block">
|
||||
OAuth Providers
|
||||
</Text>
|
||||
</Box>
|
||||
{dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ===
|
||||
0 &&
|
||||
usersCount !== 0 && (
|
||||
<Box className="flex flex-col items-center justify-center px-48 py-12 space-y-5 border-b border-x">
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 border-x border-b px-48 py-12">
|
||||
<UserIcon
|
||||
strokeWidth={1}
|
||||
className="w-10 h-10"
|
||||
className="h-10 w-10"
|
||||
sx={{ color: 'text.disabled' }}
|
||||
/>
|
||||
<div className="flex flex-col space-y-1">
|
||||
<Text className="font-medium text-center" variant="h3">
|
||||
<Text className="text-center font-medium" variant="h3">
|
||||
No results for "{searchString}"
|
||||
</Text>
|
||||
<Text variant="subtitle1" className="text-center">
|
||||
@@ -388,9 +388,5 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<ProjectLayout contentContainerProps={{ className: 'h-full' }}>
|
||||
{page}
|
||||
</ProjectLayout>
|
||||
);
|
||||
return <ProjectLayout>{page}</ProjectLayout>;
|
||||
};
|
||||
|
||||
15
dashboard/src/pages/orgs/_/[...slug].tsx
Normal file
15
dashboard/src/pages/orgs/_/[...slug].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { SelectOrg } from '@/components/common/SelectOrg';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function SelectOrganization() {
|
||||
return <SelectOrg />
|
||||
}
|
||||
|
||||
SelectOrganization.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select an Organization">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
|
||||
15
dashboard/src/pages/orgs/_/projects/_/[...slug].tsx
Normal file
15
dashboard/src/pages/orgs/_/projects/_/[...slug].tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import { SelectOrgAndProject } from '@/components/common/SelectOrgAndProject';
|
||||
|
||||
export default function OrganizationAndProject() {
|
||||
return <SelectOrgAndProject />
|
||||
}
|
||||
|
||||
OrganizationAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
14
dashboard/src/pages/orgs/_/projects/_/index.tsx
Normal file
14
dashboard/src/pages/orgs/_/projects/_/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { SelectOrgAndProject } from '@/components/common/SelectOrgAndProject';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { } from '@/utils/__generated__/graphql';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function SelectOrganizationAndProject() {
|
||||
return <SelectOrgAndProject />
|
||||
}
|
||||
|
||||
SelectOrganizationAndProject.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout title="Select a Project">{page}</AuthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@@ -9,13 +9,13 @@ import { EnvelopeIcon } from '@/components/ui/v2/icons/EnvelopeIcon';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
useGetOrganizationsQuery,
|
||||
type GetAllWorkspacesAndProjectsQuery,
|
||||
type GetOrganizationsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { styled } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
@@ -175,14 +175,14 @@ function TicketPage() {
|
||||
className="flex flex-col items-center justify-center py-10"
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<div className="flex flex-col w-full max-w-3xl">
|
||||
<div className="flex flex-col items-center mb-4">
|
||||
<div className="flex w-full max-w-3xl flex-col">
|
||||
<div className="mb-4 flex flex-col items-center">
|
||||
<Text variant="h4" className="font-bold">
|
||||
Nhost Support
|
||||
</Text>
|
||||
<Text variant="h4">How can we help you?</Text>
|
||||
</div>
|
||||
<Box className="w-full p-10 border rounded-md">
|
||||
<Box className="w-full rounded-md border p-10">
|
||||
<Box className="grid grid-flow-row gap-4">
|
||||
<Box className="flex flex-col gap-4">
|
||||
<FormProvider {...form}>
|
||||
@@ -205,7 +205,7 @@ function TicketPage() {
|
||||
helperText={errors.organization?.message}
|
||||
disabled={!!selectedWorkspace}
|
||||
renderValue={(option) => (
|
||||
<span className="inline-grid items-center grid-flow-col gap-2">
|
||||
<span className="inline-grid grid-flow-col items-center gap-2">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -238,7 +238,7 @@ function TicketPage() {
|
||||
helperText={errors.workspace?.message}
|
||||
disabled={!!selectedOrganization}
|
||||
renderValue={(option) => (
|
||||
<span className="inline-grid items-center grid-flow-col gap-2">
|
||||
<span className="inline-grid grid-flow-col items-center gap-2">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -267,7 +267,7 @@ function TicketPage() {
|
||||
error={!!errors.project}
|
||||
helperText={errors.project?.message}
|
||||
renderValue={(option) => (
|
||||
<span className="inline-grid items-center grid-flow-col gap-2">
|
||||
<span className="inline-grid grid-flow-col items-center gap-2">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -290,10 +290,10 @@ function TicketPage() {
|
||||
<ControlledAutocomplete
|
||||
id="services"
|
||||
name="services"
|
||||
label="services"
|
||||
label="Services"
|
||||
fullWidth
|
||||
multiple
|
||||
aria-label="Enabled APIs"
|
||||
aria-label="Services"
|
||||
options={[
|
||||
'Dashboard',
|
||||
'Database',
|
||||
@@ -318,7 +318,7 @@ function TicketPage() {
|
||||
root: { className: 'grid grid-flow-col gap-1 mb-4' },
|
||||
}}
|
||||
renderValue={(option) => (
|
||||
<span className="inline-grid items-center grid-flow-col gap-2">
|
||||
<span className="inline-grid grid-flow-col items-center gap-2">
|
||||
{option?.label}
|
||||
</span>
|
||||
)}
|
||||
@@ -401,8 +401,8 @@ function TicketPage() {
|
||||
helperText={errors.ccs?.message}
|
||||
/>
|
||||
|
||||
<Box className="flex flex-col gap-4 ml-auto w-80">
|
||||
<Text color="secondary" className="text-sm text-right">
|
||||
<Box className="ml-auto flex w-80 flex-col gap-4">
|
||||
<Text color="secondary" className="text-right text-sm">
|
||||
We will contact you at <strong>{user?.email}</strong>
|
||||
</Text>
|
||||
<Button
|
||||
@@ -429,12 +429,7 @@ function TicketPage() {
|
||||
|
||||
TicketPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<AuthenticatedLayout
|
||||
title="Help & Support | Nhost"
|
||||
contentContainerProps={{
|
||||
className: 'flex w-full flex-col h-screen overflow-auto',
|
||||
}}
|
||||
>
|
||||
<AuthenticatedLayout title="Help & Support | Nhost">
|
||||
{page}
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
|
||||
22227
dashboard/src/utils/__generated__/graphite.graphql.ts
generated
22227
dashboard/src/utils/__generated__/graphite.graphql.ts
generated
File diff suppressed because it is too large
Load Diff
689
dashboard/src/utils/__generated__/graphql.ts
generated
689
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,17 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a99f034: chore: fix function name
|
||||
|
||||
## 2.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 14e6100: feat: add documentation for sign-in with ID token
|
||||
|
||||
## 2.22.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -34,6 +34,9 @@ nhost
|
||||
│ │ ├── password-reset
|
||||
│ │ │ ├── body.html
|
||||
│ │ │ └── subject.txt
|
||||
│ │ ├── signin-otp
|
||||
│ │ │ ├── body.html
|
||||
│ │ │ └── subject.txt
|
||||
│ │ ├── signin-passwordless
|
||||
│ │ │ ├── body.html
|
||||
│ │ │ └── subject.txt
|
||||
@@ -49,6 +52,9 @@ nhost
|
||||
│ ├── password-reset
|
||||
│ │ ├── body.html
|
||||
│ │ └── subject.txt
|
||||
│ ├── signin-otp
|
||||
│ │ ├── body.html
|
||||
│ │ └── subject.txt
|
||||
│ ├── signin-passwordless
|
||||
│ │ ├── body.html
|
||||
│ │ └── subject.txt
|
||||
@@ -82,7 +88,7 @@ The following variables are available to all email templates:
|
||||
| `serverUrl` | URL of the authentication server |
|
||||
| `clientUrl` | URL of your client app |
|
||||
| `redirectTo` | URL where the user will be redirected to after clicking the link and finishing the action of the email |
|
||||
| `ticket` | Ticket that is used to authorize the link request |
|
||||
| `ticket` | Ticket or OTP that is used to authorize the request. |
|
||||
| `displayName` | The display name of the user |
|
||||
| `email` | The email of the user |
|
||||
| `locale` | Locale of the user as a two-letter language code (e.g. "en") |
|
||||
|
||||
93
docs/guides/auth/sign-in-idtokens.mdx
Normal file
93
docs/guides/auth/sign-in-idtokens.mdx
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
title: Sign In with ID tokens
|
||||
sidebarTitle: IDTokens
|
||||
description: Learn about ID tokens
|
||||
icon: binary
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
ID tokens are tokens provided by identity providers that contain authenticated user information and are specifically designed for authentication purposes, unlike access tokens which are used for authorization. ID tokens include claims about the user's identity, such as user ID, name, and email, along with metadata like token expiration time and intended audience.
|
||||
|
||||
ID tokens serve as a secure proof that a user has already been authenticated by a trusted identity provider. When someone logs in through their device's built-in authentication (like Sign in with Apple on iOS/macOS or Google Sign-in on Android), the system generates an ID token. This token can then be passed to your authentication service, confirming the user's identity without requiring them to log in again. This streamlined approach works with any OpenID Connect (OIDC) provider, including popular services like Google One Tap sign-in, making the authentication process both secure and user-friendly.
|
||||
|
||||
## Usage
|
||||
|
||||
To use ID tokens, you need to configure supported identity providers (currently [apple](/guides/auth/social/sign-in-apple) and [google](/guides/auth/social/sign-in-google)) and make sure the `audience` is set correctly.
|
||||
|
||||
### Sign in
|
||||
|
||||
Once everything is configured you can use an ID token to authenticate users with just a single call:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="javascript">
|
||||
```js
|
||||
nhost.auth.signInIdToken({
|
||||
provider: 'google', // The provider name, e.g., 'google', 'apple', etc.
|
||||
idToken: '...', // The ID token issued by the provider.
|
||||
nonce: '...' // Optional: The nonce used during token generation.
|
||||
})
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="react">See [react docs](/reference/react/use-sign-in-id-token) for details</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="vue">See [vue docs](/reference/react/use-sign-in-id-token) for details</Tab>
|
||||
|
||||
<Tab title="dart">
|
||||
```dart
|
||||
nhost.auth.signInIdToken(provider: 'google', idToken: '...', nonce: '...');
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
### Link Provider to existing user
|
||||
|
||||
Similarly to the [Social Connect](/guides/auth/social-connect) feature, you can link an identity provider to an existing user:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="javascript">
|
||||
```js
|
||||
nhost.auth.linkIdToken({
|
||||
provider: 'google', // The provider name, e.g., 'google', 'apple', etc.
|
||||
idToken: '...', // The ID token issued by the provider.
|
||||
nonce: '...' // Optional: The nonce used during token generation.
|
||||
})
|
||||
```
|
||||
|
||||
{' '}
|
||||
<Note>Keep in mind this is an authenticated method so the user must be logged in already.</Note>
|
||||
|
||||
</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="react">See [react docs](/reference/react/use-link-id-token) for details</Tab>
|
||||
|
||||
{' '}
|
||||
<Tab title="vue">See [vue docs](/reference/vue/use-link-id-token) for details</Tab>
|
||||
|
||||
<Tab title="dart">
|
||||
```dart
|
||||
nhost.auth.linkIdToken(provider: 'google', idToken: '...', nonce: '...');
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Examples
|
||||
|
||||
Below you can find some examples on how to extract an ID Token from various identity providers to be used with the Auth service. Keep in mind these are just some examples, use cases and sources are not limited to the examples below.
|
||||
|
||||
### React Native
|
||||
|
||||
#### Apple
|
||||
|
||||
For an example on how to authenticate using "Sign in with Apple" on iOS using React Native you can refer to our [sample component](https://github.com/nhost/nhost/blob/main/examples/react_native/src/components/SignInWithAppleButton.tsx).
|
||||
|
||||
#### Google
|
||||
|
||||
For an example on how to authenticate using "Sign in with Google" on Android using React Native you can refer to our [sample component](https://github.com/nhost/nhost/blob/main/examples/react_native/src/components/SignInWithGoogleButton.tsx).
|
||||
@@ -40,7 +40,7 @@ The domains in the URLs above will all return the IP address for localhost, `127
|
||||
local.auth.local.nhost.run has address 127.0.0.1
|
||||
```
|
||||
|
||||
However, those URLs are powered by a dynamic DNS that can return any IPv4 address you need, you just need to replace the subdomain `local` with a `subdomain` that contains the 4 octets of the IPv4 adress you want separated by `-`. For instance:
|
||||
However, those URLs are powered by a dynamic DNS that can return any IPv4 address you need, you just need to replace the subdomain `local` with a `subdomain` that contains the 4 octets of the IPv4 address you want separated by `-`. For instance:
|
||||
|
||||
```
|
||||
> host 192-168-100-1.auth.local.nhost.run
|
||||
@@ -52,6 +52,13 @@ However, those URLs are powered by a dynamic DNS that can return any IPv4 addres
|
||||
|
||||
This is useful if you need to connect to your environment from a different device, a VM or a mobile device emulator.
|
||||
|
||||
<Warning>
|
||||
Some ISPs filter DNS responses that point to localhost and/or private IP space. If your provider is one of them you may have troubles accessing your local dev environment. As a workaround you have two options:
|
||||
|
||||
1. Follow the instructions under [offline access](/guides/cli/subdomain#offline-access) to create static DNS entries in your machine.
|
||||
2. Configure your computer to use a different [DNS provider](https://privacysavvy.com/security/business/best-free-public-dns-servers/).
|
||||
</Warning>
|
||||
|
||||
To make use of this functionality you can start your development environment after setting the environment variable `NHOST_LOCAL_SUBDOMAIN` or passing the flag `--local-subdomain` :
|
||||
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user