Compare commits
40 Commits
@nhost/das
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76e77da5de | ||
|
|
04d2ce110a | ||
|
|
b2755045c9 | ||
|
|
d43931e761 | ||
|
|
44c1e17fd5 | ||
|
|
5df6fa2d0b | ||
|
|
1fa6cc47ec | ||
|
|
4854df4559 | ||
|
|
865dd93fbe | ||
|
|
0c50816717 | ||
|
|
d3b4fc358e | ||
|
|
b3bcacb300 | ||
|
|
aa7ecdb38f | ||
|
|
20672c7a9b | ||
|
|
29d27e19b4 | ||
|
|
46fc520707 | ||
|
|
21e90da476 | ||
|
|
b944d053d0 | ||
|
|
6902a36512 | ||
|
|
aea6d186c2 | ||
|
|
a535aa3834 | ||
|
|
c9dca09478 | ||
|
|
414896491f | ||
|
|
cdf6776523 | ||
|
|
1b40e99530 | ||
|
|
8b5c4a0951 | ||
|
|
f5594ef991 | ||
|
|
eb9556280c | ||
|
|
c87736eeeb | ||
|
|
714dffa5ec | ||
|
|
760835d80f | ||
|
|
6a34f891a5 | ||
|
|
037bd74764 | ||
|
|
0f6ce52c4e | ||
|
|
6696172bcb | ||
|
|
b0e848d353 | ||
|
|
cea3ef5c8a | ||
|
|
a05db74bb6 | ||
|
|
73f3d69776 | ||
|
|
a99f034bd4 |
@@ -14,22 +14,22 @@ runs:
|
||||
steps:
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 8.10.5
|
||||
version: 9.15.0
|
||||
run_install: false
|
||||
- name: Get pnpm cache directory
|
||||
id: pnpm-cache-dir
|
||||
shell: bash
|
||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v3
|
||||
- uses: actions/cache@v4
|
||||
id: pnpm-cache
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
- name: Use Node.js v18
|
||||
- name: Use Node.js v20
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
- shell: bash
|
||||
name: Install packages
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
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:
|
||||
|
||||
3
.github/workflows/gen_ai_review.yaml
vendored
3
.github/workflows/gen_ai_review.yaml
vendored
@@ -12,12 +12,11 @@ jobs:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
contents: write
|
||||
name: Run pr agent on every pull request, respond to user comments
|
||||
steps:
|
||||
- name: PR Agent action step
|
||||
id: pragent
|
||||
uses: Codium-ai/pr-agent@v0.24
|
||||
uses: Codium-ai/pr-agent@v0.26
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
// $schema provides code completion hints to IDEs.
|
||||
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
|
||||
"moderate": true,
|
||||
"allowlist": ["vue-template-compiler", "micromatch", "path-to-regexp"]
|
||||
"allowlist": ["vue-template-compiler"]
|
||||
}
|
||||
|
||||
@@ -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,74 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.14.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- d43931e: fix: invalid organization slug/project subdomain doesn't open 404 page
|
||||
- 5df6fa2: feat: add unencrypted disk warning in storage capacity settings
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 44c1e17: chore: update `msw` to v1.3.5 to fix vulnerabilities
|
||||
- @nhost/react-apollo@16.0.0
|
||||
- @nhost/nextjs@2.2.1
|
||||
|
||||
## 2.13.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 21e90da: chore: remove restrictions on SMTP sender so My Name <name@acme.com> can be added
|
||||
- 865dd93: fix: duplicate Run placeholders when there is an error in the backend
|
||||
- 6902a36: fix: can remove resources if postgres capacity is higher than 10
|
||||
- a535aa3: fix: fetch user roles locally in auth section
|
||||
- 0c50816: fix: allow decimal numbers in database row insert
|
||||
- aea6d18: chore: add warning when pausing a project about losing Run services persistent volume data
|
||||
- d3b4fc3: feat: allow to change postgres settings if project is paused
|
||||
- 29d27e1: chore: update `next` to v14.2.22 to fix vulnerabilities
|
||||
- c9dca09: feat: add reset password form
|
||||
- b3bcacb: fix: paused project banner cannot read null project name
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [46fc520]
|
||||
- Updated dependencies [29d27e1]
|
||||
- @nhost/nextjs@2.2.0
|
||||
- @nhost/react-apollo@15.0.1
|
||||
|
||||
## 2.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- eb95562: fix: show all available permission variables in permission dropdown select
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 8b5c4a0: chore: cleanup layout and add disable duplicate atom key checking in development mode
|
||||
|
||||
## 2.11.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 714dffa: fix: improve project polling logic and unify usage across components
|
||||
|
||||
## 2.11.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6a34f89: fix: improve project polling logic and unify usage across components
|
||||
|
||||
## 2.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 0f6ce52: fix: consolidate useProject hook and fix jwt expired error
|
||||
|
||||
## 2.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cea3ef5: Feat: add org and project placeholders
|
||||
|
||||
## 2.10.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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.10.0",
|
||||
"version": "2.14.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "next dev",
|
||||
"dev": "RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false next dev",
|
||||
"build": "next build --no-lint",
|
||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||
"start": "next start",
|
||||
@@ -84,7 +84,7 @@
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "^14.2.10",
|
||||
"next": "^14.2.22",
|
||||
"next-nprogress-bar": "^2.3.13",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -177,7 +177,7 @@
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"lint-staged": "^15.2.2",
|
||||
"msw": "^1.3.3",
|
||||
"msw": "^1.3.5",
|
||||
"msw-storybook-addon": "^1.10.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"postcss": "^8.4.38",
|
||||
|
||||
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';
|
||||
@@ -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,
|
||||
|
||||
@@ -21,23 +21,22 @@ export default function UnauthenticatedLayout({
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const isOnResetPassword = router.route === '/password/reset';
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlatform || (!isLoading && isAuthenticated)) {
|
||||
router.push('/');
|
||||
// we do not want to redirect if the user tries to reset their password
|
||||
if (!isOnResetPassword) {
|
||||
router.push('/');
|
||||
}
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
||||
}, [isLoading, isAuthenticated, router, isPlatform, isOnResetPassword]);
|
||||
|
||||
if (!isPlatform || isLoading || isAuthenticated) {
|
||||
if ((!isPlatform || isLoading || isAuthenticated) && !isOnResetPassword) {
|
||||
return (
|
||||
<BaseLayout {...props}>
|
||||
<LoadingScreen
|
||||
sx={{ backgroundColor: (theme) => theme.palette.background.default }}
|
||||
slotProps={{
|
||||
activityIndicator: {
|
||||
className: 'text-white',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</BaseLayout>
|
||||
);
|
||||
@@ -59,19 +58,19 @@ export default function UnauthenticatedLayout({
|
||||
|
||||
<RetryableErrorBoundary>
|
||||
<Box
|
||||
className="flex items-center min-h-screen"
|
||||
className="flex min-h-screen items-center"
|
||||
sx={{ backgroundColor: (theme) => theme.palette.common.black }}
|
||||
>
|
||||
<Container
|
||||
rootClassName="bg-transparent h-full"
|
||||
className="grid items-center w-full h-full gap-12 pt-8 pb-12 bg-transparent justify-items-center lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
||||
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
||||
>
|
||||
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
|
||||
<div className="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center w-full h-full max-w-xl mx-auto overflow-hidden opacity-70">
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
|
||||
<Image
|
||||
priority
|
||||
src="/assets/line-grid.svg"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]';
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWith
|
||||
|
||||
const validationSchema = yup
|
||||
.object({
|
||||
sender: yup.string().label('SMTP Sender').email().required(),
|
||||
sender: yup.string().label('SMTP Sender').required(),
|
||||
password: yup.string().label('Password').required(),
|
||||
})
|
||||
.required();
|
||||
|
||||
@@ -30,7 +30,7 @@ const smtpValidationSchema = yup
|
||||
user: yup.string().label('Username').required(),
|
||||
password: yup.string().label('Password'),
|
||||
method: yup.string().required(),
|
||||
sender: yup.string().label('SMTP Sender').email().required(),
|
||||
sender: yup.string().label('SMTP Sender').required(),
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
@@ -16,18 +16,20 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import { EditUserPasswordForm } from '@/features/orgs/projects/authentication/users/components/EditUserPasswordForm';
|
||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { format } from 'date-fns';
|
||||
@@ -106,6 +108,8 @@ export default function EditUserForm({
|
||||
onDeleteUser,
|
||||
roles,
|
||||
}: EditUserFormProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const theme = useTheme();
|
||||
const { onDirtyStateChange, openDialog } = useDialog();
|
||||
const { project } = useProject();
|
||||
@@ -196,6 +200,7 @@ export default function EditUserForm({
|
||||
|
||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const allAvailableProjectRoles = getUserRoles(
|
||||
@@ -206,6 +211,7 @@ export default function EditUserForm({
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
},
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
@@ -61,6 +63,8 @@ export interface UsersBodyProps {
|
||||
|
||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||
const { project } = useProject();
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
@@ -88,6 +92,7 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
*/
|
||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
||||
|
||||
@@ -36,8 +36,8 @@ export default function ApplicationPaused() {
|
||||
>
|
||||
<RemoveApplicationModal
|
||||
close={() => setShowDeletingModal(false)}
|
||||
title={`Remove project ${project.name}?`}
|
||||
description={`The project ${project.name} will be removed. All data will be lost and there will be no way to
|
||||
title={`Remove project ${project?.name}?`}
|
||||
description={`The project ${project?.name} will be removed. All data will be lost and there will be no way to
|
||||
recover the app once it has been deleted.`}
|
||||
className="z-50"
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
|
||||
/**
|
||||
@@ -9,7 +9,7 @@ export default function useAppState(): {
|
||||
state: ApplicationStatus;
|
||||
message?: string;
|
||||
} {
|
||||
const { project } = useProject({ poll: true });
|
||||
const { project } = useProjectWithState();
|
||||
const noApplication = !project;
|
||||
|
||||
if (noApplication) {
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function useProjectRedirectWhenReady(
|
||||
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
||||
...options,
|
||||
variables: { ...options.variables, appId: project?.id },
|
||||
skip: !project.id,
|
||||
skip: !project?.id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -65,8 +65,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
||||
<ColumnAutocomplete
|
||||
{...args}
|
||||
name="firstReference"
|
||||
label="First Reference"
|
||||
onChange={(_event, newValue) =>
|
||||
onChange={(newValue) =>
|
||||
form.setValue('firstReference', newValue.value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
@@ -80,8 +79,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
||||
<ColumnAutocomplete
|
||||
{...args}
|
||||
name="secondReference"
|
||||
label="Second Reference"
|
||||
onChange={(_event, newValue) =>
|
||||
onChange={(newValue) =>
|
||||
form.setValue('secondReference', newValue.value, {
|
||||
shouldDirty: true,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,10 @@ import { setupServer } from 'msw/node';
|
||||
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
tableQuery,
|
||||
hasuraMetadataQuery,
|
||||
@@ -21,17 +25,9 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
test('should render a combobox', () => {
|
||||
render(
|
||||
<ColumnAutocomplete
|
||||
schema="public"
|
||||
table="books"
|
||||
label="Column Autocomplete"
|
||||
/>,
|
||||
);
|
||||
render(<ColumnAutocomplete schema="public" table="books" />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: /column autocomplete/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Note: Network requests don't go through in tests, so we can't test the
|
||||
|
||||
@@ -1,39 +1,33 @@
|
||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
||||
import { AutocompletePopper } from '@/components/ui/v2/Autocomplete';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ArrowLeftIcon } from '@/components/ui/v2/icons/ArrowLeftIcon';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { OptionBase } from '@/components/ui/v2/Option';
|
||||
import { OptionGroupBase } from '@/components/ui/v2/OptionGroup';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { useMetadataQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useMetadataQuery';
|
||||
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
||||
import { getTruncatedText } from '@/utils/getTruncatedText';
|
||||
import type { AutocompleteGroupedOption } from '@mui/base/useAutocomplete';
|
||||
import { useAutocomplete } from '@mui/base/useAutocomplete';
|
||||
import type { AutocompleteRenderGroupParams } from '@mui/material/Autocomplete';
|
||||
import { autocompleteClasses } from '@mui/material/Autocomplete';
|
||||
import type {
|
||||
ChangeEvent,
|
||||
ForwardedRef,
|
||||
HTMLAttributes,
|
||||
PropsWithoutRef,
|
||||
SyntheticEvent,
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronLeft, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import useRuleGroupEditor from '@/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/useRuleGroupEditor';
|
||||
import { CommandLoading } from 'cmdk';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { UseAsyncValueOptions } from './useAsyncValue';
|
||||
import useAsyncValue from './useAsyncValue';
|
||||
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
||||
import useColumnGroups from './useColumnGroups';
|
||||
|
||||
export interface ColumnAutocompleteProps
|
||||
extends Omit<PropsWithoutRef<InputProps>, 'onChange'> {
|
||||
export interface ColumnAutocompleteProps extends Omit<ButtonProps, 'onChange'> {
|
||||
value?: string;
|
||||
/**
|
||||
* Schema where the `table` is located.
|
||||
*/
|
||||
@@ -45,70 +39,39 @@ export interface ColumnAutocompleteProps
|
||||
/**
|
||||
* Function to be called when the value changes.
|
||||
*/
|
||||
onChange?: (
|
||||
event: SyntheticEvent,
|
||||
value: {
|
||||
value: string;
|
||||
columnMetadata?: Record<string, any>;
|
||||
disableReset?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onChange?: (value: {
|
||||
value: string;
|
||||
columnMetadata?: Record<string, any>;
|
||||
disableReset?: boolean;
|
||||
}) => void;
|
||||
/**
|
||||
* Function to be called when the input is asynchronously initialized.
|
||||
*/
|
||||
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
||||
/**
|
||||
* Class name to be applied to the root element.
|
||||
*/
|
||||
rootClassName?: string;
|
||||
/**
|
||||
* Determines if the autocomplete should allow relationships.
|
||||
*/
|
||||
disableRelationships?: UseColumnGroupsOptions['disableRelationships'];
|
||||
}
|
||||
|
||||
function renderGroup(params: AutocompleteRenderGroupParams) {
|
||||
return (
|
||||
<li key={params.key}>
|
||||
<OptionGroupBase>{params.group}</OptionGroupBase>
|
||||
|
||||
<List>{params.children}</List>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function renderOption(
|
||||
option: AutocompleteOption<string>,
|
||||
optionProps: HTMLAttributes<HTMLLIElement>,
|
||||
) {
|
||||
return (
|
||||
<OptionBase
|
||||
{...optionProps}
|
||||
className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5"
|
||||
>
|
||||
<Text component="span">{option.label}</Text>
|
||||
|
||||
{option.group === 'columns' && (
|
||||
<InlineCode>{option.metadata?.udt_name || option.value}</InlineCode>
|
||||
)}
|
||||
</OptionBase>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnAutocomplete(
|
||||
{
|
||||
rootClassName,
|
||||
schema: defaultSchema,
|
||||
table: defaultTable,
|
||||
value: externalValue,
|
||||
disableRelationships,
|
||||
onChange,
|
||||
onInitialized,
|
||||
...props
|
||||
}: ColumnAutocompleteProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
const { disabled } = useRuleGroupEditor();
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const [activeRelationship, setActiveRelationship] = useState<{
|
||||
schema: string;
|
||||
table: string;
|
||||
@@ -120,7 +83,6 @@ function ColumnAutocomplete(
|
||||
const {
|
||||
data: tableData,
|
||||
status: tableStatus,
|
||||
error: tableError,
|
||||
isFetching: isTableFetching,
|
||||
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
||||
schema: selectedSchema,
|
||||
@@ -132,7 +94,6 @@ function ColumnAutocomplete(
|
||||
const {
|
||||
data: metadata,
|
||||
status: metadataStatus,
|
||||
error: metadataError,
|
||||
isFetching: isMetadataFetching,
|
||||
} = useMetadataQuery([`default.metadata`], {
|
||||
queryOptions: { refetchOnWindowFocus: false },
|
||||
@@ -140,8 +101,6 @@ function ColumnAutocomplete(
|
||||
|
||||
const {
|
||||
initialized,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
selectedColumn,
|
||||
setSelectedColumn,
|
||||
selectedRelationships,
|
||||
@@ -159,57 +118,20 @@ function ColumnAutocomplete(
|
||||
onInitialized,
|
||||
});
|
||||
|
||||
const [pages, setPages] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setPages(
|
||||
relationshipDotNotation ? [relationshipDotNotation?.split('.')[0]] : [],
|
||||
);
|
||||
}, [relationshipDotNotation]);
|
||||
|
||||
const activePage = pages[pages.length - 1];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveRelationship(asyncActiveRelationship);
|
||||
}, [asyncActiveRelationship]);
|
||||
|
||||
function isOptionEqualToValue(
|
||||
option: AutocompleteOption,
|
||||
value: NonNullable<string | AutocompleteOption>,
|
||||
) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return option.value === value;
|
||||
}
|
||||
|
||||
return option.value === value.value && option.custom === value.custom;
|
||||
}
|
||||
|
||||
function handleChange(
|
||||
event: SyntheticEvent,
|
||||
value: NonNullable<string | AutocompleteOption>,
|
||||
) {
|
||||
if (typeof value === 'string' || Array.isArray(value) || !value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ('group' in value && value.group === 'columns') {
|
||||
setSelectedColumn(value);
|
||||
setOpen(false);
|
||||
setInputValue(value.value);
|
||||
|
||||
onChange?.(event, {
|
||||
value:
|
||||
selectedRelationships.length > 0
|
||||
? [relationshipDotNotation, value.value].join('.')
|
||||
: value.value,
|
||||
columnMetadata: value.metadata,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setInputValue('');
|
||||
setSelectedColumn(null);
|
||||
setSelectedRelationships((currentRelationships) => [
|
||||
...currentRelationships,
|
||||
value.metadata?.target,
|
||||
]);
|
||||
}
|
||||
|
||||
const options = useColumnGroups({
|
||||
selectedSchema,
|
||||
selectedTable,
|
||||
@@ -218,246 +140,214 @@ function ColumnAutocomplete(
|
||||
disableRelationships,
|
||||
});
|
||||
|
||||
const {
|
||||
popupOpen,
|
||||
anchorEl,
|
||||
setAnchorEl,
|
||||
getRootProps,
|
||||
getInputLabelProps,
|
||||
getInputProps,
|
||||
getListboxProps,
|
||||
getOptionProps,
|
||||
groupedOptions,
|
||||
} = useAutocomplete({
|
||||
open,
|
||||
inputValue,
|
||||
options,
|
||||
id: props?.name,
|
||||
openOnFocus: !props.disabled,
|
||||
disableCloseOnSelect: true,
|
||||
value: selectedColumn,
|
||||
onClose: () => setOpen(false),
|
||||
groupBy: (option) => option.group,
|
||||
isOptionEqualToValue,
|
||||
onChange: handleChange,
|
||||
});
|
||||
const handleChange = (newValue: string) => {
|
||||
const selectedOption = options.find((option) => option.value === newValue);
|
||||
|
||||
function handleInputValueChange(
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
const { value } = event.target;
|
||||
setInputValue(value);
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedColumn({
|
||||
value,
|
||||
label: value,
|
||||
metadata: selectedColumn?.metadata || {
|
||||
table_schema: selectedSchema,
|
||||
table_name: selectedTable,
|
||||
},
|
||||
});
|
||||
setSelectedColumn(selectedOption);
|
||||
setOpen(false);
|
||||
setValue(newValue === value ? '' : newValue);
|
||||
|
||||
onChange?.(event, {
|
||||
const valueObj = {
|
||||
value:
|
||||
selectedRelationships.length > 0
|
||||
? [relationshipDotNotation, value].join('.')
|
||||
: value,
|
||||
columnMetadata: {
|
||||
table_schema: selectedSchema,
|
||||
table_name: selectedTable,
|
||||
},
|
||||
});
|
||||
}
|
||||
? [relationshipDotNotation, newValue].join('.')
|
||||
: newValue,
|
||||
columnMetadata: selectedOption.metadata,
|
||||
};
|
||||
|
||||
onChange?.(valueObj);
|
||||
};
|
||||
|
||||
const handleRelationshipChange = (newValue: string) => {
|
||||
const selectedOption = options.find((option) => option.value === newValue);
|
||||
|
||||
if (!selectedOption) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPages((p) => [...p, newValue]);
|
||||
setSelectedColumn(null);
|
||||
setSearch('');
|
||||
setSelectedRelationships((currentRelationships) => [
|
||||
...currentRelationships,
|
||||
selectedOption.metadata?.target,
|
||||
]);
|
||||
};
|
||||
|
||||
const columns = options.filter((option) => option.group === 'columns');
|
||||
const relationships = options.filter(
|
||||
(option) => option.group === 'relationships',
|
||||
);
|
||||
|
||||
const handleBackRelationship = () => {
|
||||
setPages((p) => p.slice(0, -1));
|
||||
setSelectedColumn(null);
|
||||
setSelectedRelationships((activeRelationships) =>
|
||||
activeRelationships.slice(0, -1),
|
||||
);
|
||||
setSearch('');
|
||||
};
|
||||
|
||||
const buttonPrefix = relationshipDotNotation
|
||||
? `${selectedTable}.${relationshipDotNotation}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div {...getRootProps()} className={rootClassName}>
|
||||
<Input
|
||||
{...props}
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
...(props.slotProps || {}),
|
||||
label: getInputLabelProps(),
|
||||
input: {
|
||||
...(props.slotProps?.input || {}),
|
||||
ref: setAnchorEl,
|
||||
sx: [
|
||||
...(Array.isArray(props.slotProps?.input?.sx)
|
||||
? props.slotProps.input.sx
|
||||
: [props.slotProps?.input?.sx || {}]),
|
||||
{
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
inputRoot: {
|
||||
...getInputProps(),
|
||||
className: twMerge(
|
||||
Boolean(selectedColumn) || Boolean(relationshipDotNotation)
|
||||
? '!pl-0'
|
||||
: null,
|
||||
props.slotProps?.inputRoot?.className,
|
||||
),
|
||||
},
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
}}
|
||||
onClick={() => {
|
||||
if (props.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
}}
|
||||
error={Boolean(tableError || metadataError) || props.error}
|
||||
helperText={
|
||||
String(tableError || metadataError || '') || props.helperText
|
||||
}
|
||||
onChange={handleInputValueChange}
|
||||
value={inputValue}
|
||||
startAdornment={
|
||||
selectedColumn || relationshipDotNotation ? (
|
||||
<Text
|
||||
component="span"
|
||||
sx={{
|
||||
color: props.disabled ? 'text.disabled' : 'text.primary',
|
||||
}}
|
||||
className="!ml-2 flex-shrink-0 truncate lg:max-w-[200px]"
|
||||
>
|
||||
<Text component="span" color="disabled">
|
||||
{selectedTable}
|
||||
</Text>
|
||||
.
|
||||
{relationshipDotNotation && (
|
||||
<>
|
||||
<span className="hidden lg:inline">
|
||||
{getTruncatedText(relationshipDotNotation, 15, 'end')}.
|
||||
</span>
|
||||
|
||||
<span className="inline lg:hidden">
|
||||
{getTruncatedText(relationshipDotNotation, 35, 'end')}.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
endAdornment={
|
||||
tableStatus === 'loading' ||
|
||||
metadataStatus === 'loading' ||
|
||||
!initialized ? (
|
||||
<ActivityIndicator className="mr-2" delay={500} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AutocompletePopper
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
modifiers={[{ name: 'offset', options: { offset: [0, 10] } }]}
|
||||
placement="bottom-start"
|
||||
open={popupOpen}
|
||||
anchorEl={anchorEl}
|
||||
style={{ width: anchorEl?.parentElement?.clientWidth }}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
{buttonPrefix ? (
|
||||
<div className="flex flex-shrink-0 gap-0 truncate">
|
||||
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||
{buttonPrefix}.
|
||||
</span>
|
||||
{selectedColumn?.label}
|
||||
</div>
|
||||
) : (
|
||||
selectedColumn?.label || 'Select a column'
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Box
|
||||
className={autocompleteClasses.paper}
|
||||
sx={{
|
||||
borderWidth: (theme) => (theme.palette.mode === 'dark' ? 1 : 0),
|
||||
borderColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.400' : 'none',
|
||||
<Command
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||
e.preventDefault();
|
||||
setPages((p) => p.slice(0, -1));
|
||||
setSelectedColumn(null);
|
||||
setSelectedRelationships((activeRelationships) =>
|
||||
activeRelationships.slice(0, -1),
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
className="grid grid-flow-col items-center justify-start gap-2 border-b-1 px-3 py-2.5"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
>
|
||||
{selectedRelationships.length > 0 && (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
setInputValue('');
|
||||
setSelectedColumn(null);
|
||||
setSelectedRelationships((activeRelationships) =>
|
||||
activeRelationships.slice(0, -1),
|
||||
);
|
||||
}}
|
||||
<CommandInput
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
autoFocus
|
||||
placeholder=""
|
||||
prefix={
|
||||
relationshipDotNotation
|
||||
? `
|
||||
${selectedTable}.${relationshipDotNotation}.`
|
||||
: ``
|
||||
}
|
||||
/>
|
||||
{pages?.length > 0 ? (
|
||||
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={handleBackRelationship}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
<Text className="direction-rtl truncate text-left">
|
||||
<Text component="span" color="disabled">
|
||||
{defaultTable}
|
||||
</Text>
|
||||
|
||||
{relationshipDotNotation && (
|
||||
<>
|
||||
<span className="hidden lg:inline">
|
||||
.{getTruncatedText(relationshipDotNotation, 20, 'start')}
|
||||
</span>
|
||||
|
||||
<span className="inline lg:hidden">
|
||||
.{relationshipDotNotation}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{(tableStatus === 'loading' ||
|
||||
metadataStatus === 'loading' ||
|
||||
!initialized) && (
|
||||
<div className="p-2">
|
||||
<ActivityIndicator label="Loading..." />
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<span className="py-1.5 text-sm text-muted-foreground">
|
||||
{defaultTable}.{pages.join('.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedOptions.length > 0 && (
|
||||
<List
|
||||
{...getListboxProps()}
|
||||
className={autocompleteClasses.listbox}
|
||||
>
|
||||
{(
|
||||
groupedOptions as AutocompleteGroupedOption<
|
||||
(typeof options)[number]
|
||||
>[]
|
||||
).map((optionGroup) =>
|
||||
renderGroup({
|
||||
key: `${optionGroup.key}`,
|
||||
group: optionGroup.group,
|
||||
children: optionGroup.options.map((option, index) =>
|
||||
renderOption(
|
||||
option,
|
||||
getOptionProps({
|
||||
option,
|
||||
index: optionGroup.index + index,
|
||||
}),
|
||||
),
|
||||
),
|
||||
}),
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
|
||||
{groupedOptions.length === 0 && Boolean(anchorEl) && (
|
||||
<Text className={autocompleteClasses.noOptions}>No options</Text>
|
||||
)}
|
||||
</Box>
|
||||
</AutocompletePopper>
|
||||
</>
|
||||
) : null}
|
||||
<CommandList>
|
||||
{!activePage && (
|
||||
<>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
{tableStatus === 'loading' ||
|
||||
metadataStatus === 'loading' ||
|
||||
(!initialized && <CommandLoading>Loading...</CommandLoading>)}
|
||||
<CommandGroup heading="columns">
|
||||
{columns.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={handleChange}
|
||||
className="overflow-x-hidden"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === option.value ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<span className="line-clamp-2 break-all">
|
||||
{option.label}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<code className="relative rounded bg-primary px-[0.2rem] font-mono text-white">
|
||||
{option.metadata?.udt_name || option.value}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{relationships.length > 0 && !disableRelationships && (
|
||||
<CommandGroup heading="relationships">
|
||||
{relationships.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={handleRelationshipChange}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{activePage && (
|
||||
<>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup heading="columns">
|
||||
{columns.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={handleChange}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
value === option.value ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<span className="line-clamp-2 break-all">
|
||||
{option.label}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
<code className="relative rounded bg-primary px-[0.2rem] font-mono text-white">
|
||||
{option.metadata?.udt_name || option.value}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ export default function useAsyncValue({
|
||||
onInitialized,
|
||||
}: UseAsyncValueOptions) {
|
||||
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
// We might not going to have the most up-to-date table data because the
|
||||
// relationship is loaded asynchronously, so we need to make sure we don't
|
||||
@@ -131,7 +130,6 @@ export default function useAsyncValue({
|
||||
),
|
||||
});
|
||||
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
||||
setInputValue(activeColumn);
|
||||
}, [
|
||||
remainingColumnPath,
|
||||
isTableLoading,
|
||||
@@ -287,8 +285,6 @@ export default function useAsyncValue({
|
||||
|
||||
return {
|
||||
initialized,
|
||||
inputValue,
|
||||
setInputValue,
|
||||
activeRelationship,
|
||||
selectedRelationships: initialized ? selectedRelationships : [],
|
||||
selectedColumn: initialized ? selectedColumn : null,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGrid } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGridBooleanCell } from '@/components/dataGrid/DataGridBooleanCell';
|
||||
import { DataGridDateCell } from '@/components/dataGrid/DataGridDateCell';
|
||||
import { DataGridNumericCell } from '@/components/dataGrid/DataGridNumericCell';
|
||||
import { DataGridTextCell } from '@/components/dataGrid/DataGridTextCell';
|
||||
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
||||
@@ -23,11 +17,19 @@ import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGri
|
||||
import {
|
||||
POSTGRESQL_CHARACTER_TYPES,
|
||||
POSTGRESQL_DATE_TIME_TYPES,
|
||||
POSTGRESQL_DECIMAL_TYPES,
|
||||
POSTGRESQL_INTEGER_TYPES,
|
||||
POSTGRESQL_JSON_TYPES,
|
||||
POSTGRESQL_NUMERIC_TYPES,
|
||||
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
|
||||
import { DataGridDateCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDateCell';
|
||||
import { DataGridDecimalCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDecimalCell';
|
||||
import { DataGridIntegerCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridIntegerCell';
|
||||
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -68,10 +70,10 @@ export function createDataGridColumn(
|
||||
|
||||
const defaultColumnConfiguration = {
|
||||
Header: () => (
|
||||
<div className="grid items-center justify-start grid-flow-col gap-1 font-normal">
|
||||
<div className="grid grid-flow-col items-center justify-start gap-1 font-normal">
|
||||
{column.is_primary && <KeyIcon className="text-sm" />}
|
||||
|
||||
<span className="font-bold truncate" title={column.column_name}>
|
||||
<span className="truncate font-bold" title={column.column_name}>
|
||||
{column.column_name}
|
||||
</span>
|
||||
|
||||
@@ -104,12 +106,21 @@ export function createDataGridColumn(
|
||||
foreignKeyRelation: column.foreign_key_relation,
|
||||
};
|
||||
|
||||
if (POSTGRESQL_NUMERIC_TYPES.includes(column.data_type)) {
|
||||
if (POSTGRESQL_INTEGER_TYPES.includes(column.data_type)) {
|
||||
return {
|
||||
...defaultColumnConfiguration,
|
||||
type: 'number',
|
||||
width: 200,
|
||||
Cell: DataGridNumericCell,
|
||||
Cell: DataGridIntegerCell,
|
||||
};
|
||||
}
|
||||
|
||||
if (POSTGRESQL_DECIMAL_TYPES.includes(column.data_type)) {
|
||||
return {
|
||||
...defaultColumnConfiguration,
|
||||
type: 'text',
|
||||
width: 200,
|
||||
Cell: DataGridDecimalCell,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -209,7 +209,6 @@ export default function DatabaseRecordInputGroup({
|
||||
autoFocus={index === 0 && autoFocusFirstInput}
|
||||
slotProps={{
|
||||
label: commonLabelProps,
|
||||
inputRoot: { step: 1 },
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -326,10 +326,10 @@ export default function RolePermissionEditorForm({
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
{error && error instanceof Error && (
|
||||
<div className="px-6 mb-4 -mt-3">
|
||||
<div className="-mt-3 mb-4 px-6">
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {error.message}
|
||||
@@ -349,13 +349,13 @@ export default function RolePermissionEditorForm({
|
||||
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="flex flex-col content-between flex-auto overflow-hidden border-t-1"
|
||||
className="flex flex-auto flex-col content-between overflow-hidden border-t-1"
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
>
|
||||
<div className="grid content-start flex-auto grid-flow-row gap-6 py-4 overflow-auto">
|
||||
<div className="grid flex-auto grid-flow-row content-start gap-6 overflow-auto py-4">
|
||||
<PermissionSettingsSection
|
||||
title="Selected role & action"
|
||||
className="justify-between grid-flow-col"
|
||||
className="grid-flow-col justify-between"
|
||||
>
|
||||
<div className="grid grid-flow-col gap-4">
|
||||
<Text>
|
||||
@@ -408,7 +408,7 @@ export default function RolePermissionEditorForm({
|
||||
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
||||
</div>
|
||||
|
||||
<Box className="grid flex-shrink-0 gap-2 p-2 border-t-1 sm:grid-flow-col sm:justify-between">
|
||||
<Box className="grid flex-shrink-0 gap-2 border-t-1 p-2 sm:grid-flow-col sm:justify-between">
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import type { HasuraOperator } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
const commonOperators: {
|
||||
value: HasuraOperator;
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
}[] = [
|
||||
{ value: '_eq', helperText: 'equal' },
|
||||
{ value: '_neq', helperText: 'not equal' },
|
||||
{ value: '_in', helperText: 'in (array)' },
|
||||
{ value: '_nin', helperText: 'not in (array)' },
|
||||
{ value: '_gt', helperText: 'greater than' },
|
||||
{ value: '_lt', helperText: 'lower than' },
|
||||
{ value: '_gte', helperText: 'greater than or equal' },
|
||||
{ value: '_lte', helperText: 'lower than or equal' },
|
||||
{ value: '_ceq', helperText: 'equal to column' },
|
||||
{ value: '_cne', helperText: 'not equal to column' },
|
||||
{ value: '_cgt', helperText: 'greater than column' },
|
||||
{ value: '_clt', helperText: 'lower than column' },
|
||||
{ value: '_cgte', helperText: 'greater than or equal to column' },
|
||||
{ value: '_clte', helperText: 'lower than or equal to column' },
|
||||
{ value: '_is_null', helperText: 'null' },
|
||||
];
|
||||
|
||||
const textSpecificOperators: typeof commonOperators = [
|
||||
{ value: '_like', helperText: 'like' },
|
||||
{ value: '_nlike', helperText: 'not like' },
|
||||
{ value: '_ilike', helperText: 'like (case-insensitive)' },
|
||||
{ value: '_nilike', helperText: 'not like (case-insensitive)' },
|
||||
{ value: '_similar', helperText: 'similar' },
|
||||
{ value: '_nsimilar', helperText: 'not similar' },
|
||||
{ value: '_regex', helperText: 'matches regex' },
|
||||
{ value: '_nregex', helperText: `doesn't match regex` },
|
||||
{ value: '_iregex', helperText: 'matches case-insensitive regex' },
|
||||
{ value: '_niregex', helperText: `doesn't match case-insensitive regex` },
|
||||
];
|
||||
|
||||
interface OperatorComboBoxProps {
|
||||
name: string;
|
||||
disabled?: boolean;
|
||||
selectedColumnType?: string;
|
||||
}
|
||||
|
||||
export default function OperatorComboBox({
|
||||
name,
|
||||
disabled,
|
||||
selectedColumnType,
|
||||
}: OperatorComboBoxProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { watch, setValue } = useFormContext();
|
||||
|
||||
const operator = watch(`${name}.operator`);
|
||||
|
||||
const availableOperators = [
|
||||
...commonOperators,
|
||||
...(selectedColumnType === 'text' ? textSpecificOperators : []),
|
||||
];
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (['_in', '_nin'].includes(value)) {
|
||||
setValue(`${name}.value`, [], { shouldDirty: true });
|
||||
}
|
||||
|
||||
setValue(`${name}.operator`, value, { shouldDirty: true });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
{operator ?? 'Select operator...'}
|
||||
<ChevronsUpDown className="h-5 w-5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="bottom" align="start" className="p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search operator..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No operator found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableOperators.map((op) => (
|
||||
<CommandItem
|
||||
key={op.value}
|
||||
keywords={[op.helperText]}
|
||||
value={op.value}
|
||||
onSelect={handleSelect}
|
||||
className="flex flex-row justify-between"
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
<span className="min-w-[9ch]">{op.value}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{op.helperText}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
op.value === operator ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import OperatorComboBox from './OperatorComboBox';
|
||||
import RuleRemoveButton from './RuleRemoveButton';
|
||||
import RuleValueInput from './RuleValueInput';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
@@ -25,69 +22,6 @@ export interface RuleEditorRowProps
|
||||
* Function to be called when the remove button is clicked.
|
||||
*/
|
||||
onRemove?: VoidFunction;
|
||||
/**
|
||||
* List of operators to be disabled for the rule editor.
|
||||
*
|
||||
* @default []
|
||||
*/
|
||||
disabledOperators?: HasuraOperator[];
|
||||
}
|
||||
|
||||
const commonOperators: {
|
||||
value: HasuraOperator;
|
||||
label?: string;
|
||||
helperText?: string;
|
||||
}[] = [
|
||||
{ value: '_eq', helperText: 'equal' },
|
||||
{ value: '_neq', helperText: 'not equal' },
|
||||
{ value: '_in_hasura', label: '_in', helperText: 'in (X-Hasura-)' },
|
||||
{ value: '_in', helperText: 'in (array)' },
|
||||
{ value: '_nin_hasura', label: '_nin', helperText: 'not in (X-Hasura-)' },
|
||||
{ value: '_nin', helperText: 'not in (array)' },
|
||||
{ value: '_gt', helperText: 'greater than' },
|
||||
{ value: '_lt', helperText: 'lower than' },
|
||||
{ value: '_gte', helperText: 'greater than or equal' },
|
||||
{ value: '_lte', helperText: 'lower than or equal' },
|
||||
{ value: '_ceq', helperText: 'equal to column' },
|
||||
{ value: '_cne', helperText: 'not equal to column' },
|
||||
{ value: '_cgt', helperText: 'greater than column' },
|
||||
{ value: '_clt', helperText: 'lower than column' },
|
||||
{ value: '_cgte', helperText: 'greater than or equal to column' },
|
||||
{ value: '_clte', helperText: 'lower than or equal to column' },
|
||||
{ value: '_is_null', helperText: 'null' },
|
||||
];
|
||||
|
||||
const textSpecificOperators: typeof commonOperators = [
|
||||
{ value: '_like', helperText: 'like' },
|
||||
{ value: '_nlike', helperText: 'not like' },
|
||||
{ value: '_ilike', helperText: 'like (case-insensitive)' },
|
||||
{ value: '_nilike', helperText: 'not like (case-insensitive)' },
|
||||
{ value: '_similar', helperText: 'similar' },
|
||||
{ value: '_nsimilar', helperText: 'not similar' },
|
||||
{ value: '_regex', helperText: 'matches regex' },
|
||||
{ value: '_nregex', helperText: `doesn't match regex` },
|
||||
{ value: '_iregex', helperText: 'matches case-insensitive regex' },
|
||||
{ value: '_niregex', helperText: `doesn't match case-insensitive regex` },
|
||||
];
|
||||
|
||||
function renderOption({
|
||||
value,
|
||||
label,
|
||||
helperText,
|
||||
}: (typeof commonOperators)[number]) {
|
||||
return (
|
||||
<Option key={value} value={value} className="grid grid-flow-col gap-2">
|
||||
<Text component="span" className="inline-block w-16">
|
||||
{label || value}
|
||||
</Text>
|
||||
|
||||
{helperText && (
|
||||
<Text component="span" color="disabled">
|
||||
{helperText}
|
||||
</Text>
|
||||
)}
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RuleEditorRow({
|
||||
@@ -95,17 +29,12 @@ export default function RuleEditorRow({
|
||||
index,
|
||||
onRemove,
|
||||
className,
|
||||
disabledOperators = [],
|
||||
...props
|
||||
}: RuleEditorRowProps) {
|
||||
const { schema, table, disabled } = useRuleGroupEditor();
|
||||
const { control, setValue, getFieldState } = useFormContext();
|
||||
const { schema, table } = useRuleGroupEditor();
|
||||
const { control, setValue } = useFormContext();
|
||||
const rowName = `${name}.rules.${index}`;
|
||||
|
||||
const columnState = getFieldState(`${rowName}.column`);
|
||||
const operatorState = getFieldState(`${rowName}.operator`);
|
||||
const valueState = getFieldState(`${rowName}.value`);
|
||||
|
||||
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
||||
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
||||
const { field: autocompleteField } = useController({
|
||||
@@ -113,48 +42,19 @@ export default function RuleEditorRow({
|
||||
control,
|
||||
});
|
||||
|
||||
const disabledOperatorMap = disabledOperators.reduce(
|
||||
(map, currentOperator) => map.set(currentOperator, true),
|
||||
new Map<string, boolean>(),
|
||||
);
|
||||
|
||||
const availableOperators = [
|
||||
...commonOperators.filter(({ value }) => !disabledOperatorMap.has(value)),
|
||||
...(selectedColumnType === 'text'
|
||||
? textSpecificOperators.filter(
|
||||
({ value }) => !disabledOperatorMap.get(value),
|
||||
)
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid grid-flow-row space-y-1 lg:max-h-10 lg:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] lg:space-y-0',
|
||||
'flex flex-col gap-1 space-y-1 overflow-x-hidden pb-4 xl:grid xl:grid-flow-row xl:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] xl:space-y-0 xl:overflow-x-visible',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ColumnAutocomplete
|
||||
{...autocompleteField}
|
||||
disabled={disabled}
|
||||
schema={schema}
|
||||
table={table}
|
||||
rootClassName="h-10"
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'lg:!rounded-r-none',
|
||||
sx: !disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.300' : 'common.white',
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
fullWidth
|
||||
error={Boolean(columnState?.error?.message)}
|
||||
onChange={(_event, { value, columnMetadata, disableReset }) => {
|
||||
onChange={({ value, columnMetadata, disableReset }) => {
|
||||
setSelectedTablePath(
|
||||
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
||||
);
|
||||
@@ -182,69 +82,21 @@ export default function RuleEditorRow({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<ControlledSelect
|
||||
disabled={disabled}
|
||||
name={`${rowName}.operator`}
|
||||
className="h-10"
|
||||
slotProps={{
|
||||
root: {
|
||||
className: 'lg:!rounded-none',
|
||||
sx: !disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.grey[300]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
}
|
||||
: {},
|
||||
},
|
||||
listbox: { className: 'max-h-[300px]' },
|
||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
||||
}}
|
||||
fullWidth
|
||||
error={Boolean(operatorState?.error?.message)}
|
||||
onChange={(_event, value: HasuraOperator) => {
|
||||
if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (value === '_in_hasura' || value === '_nin_hasura') {
|
||||
setValue(`${rowName}.value`, null, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(`${rowName}.value`, [], { shouldDirty: true });
|
||||
}}
|
||||
renderValue={(option) => {
|
||||
if (!option?.value) {
|
||||
return <span />;
|
||||
}
|
||||
|
||||
if (option.value === '_in_hasura') {
|
||||
return <span>_in</span>;
|
||||
}
|
||||
|
||||
if (option.value === '_nin_hasura') {
|
||||
return <span>_nin</span>;
|
||||
}
|
||||
|
||||
return <span>{option.value}</span>;
|
||||
}}
|
||||
>
|
||||
{availableOperators.map(renderOption)}
|
||||
</ControlledSelect>
|
||||
|
||||
<OperatorComboBox
|
||||
name={rowName}
|
||||
selectedColumnType={selectedColumnType}
|
||||
/>
|
||||
<RuleValueInput
|
||||
selectedTablePath={selectedTablePath}
|
||||
name={rowName}
|
||||
error={Boolean(valueState?.error?.message)}
|
||||
className="min-h-10"
|
||||
/>
|
||||
|
||||
<RuleRemoveButton onRemove={onRemove} name={name} disabled={disabled} />
|
||||
<RuleRemoveButton
|
||||
className="w-full xl:w-auto"
|
||||
onRemove={onRemove}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
|
||||
@@ -32,9 +37,11 @@ export default function RuleGroupControls({
|
||||
...props
|
||||
}: RuleGroupControlsProps) {
|
||||
const { disabled } = useRuleGroupEditor();
|
||||
const inputName = `${name}.operator`;
|
||||
const currentOperator: RuleGroup['operator'] = useWatch({
|
||||
name: `${name}.operator`,
|
||||
name: inputName,
|
||||
});
|
||||
const { setValue } = useFormContext();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -42,24 +49,26 @@ export default function RuleGroupControls({
|
||||
{...props}
|
||||
>
|
||||
{showSelect ? (
|
||||
<ControlledSelect
|
||||
<Select
|
||||
disabled={disabled}
|
||||
name={`${name}.operator`}
|
||||
slotProps={{
|
||||
root: {
|
||||
sx: {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.grey[300]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
},
|
||||
name={inputName}
|
||||
onValueChange={(newValue: string) => {
|
||||
setValue(inputName, newValue, { shouldDirty: true });
|
||||
}}
|
||||
fullWidth
|
||||
defaultValue={currentOperator}
|
||||
>
|
||||
<Option value="_and">and</Option>
|
||||
<Option value="_or">or</Option>
|
||||
</ControlledSelect>
|
||||
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="_and">
|
||||
<span className="font-medium">and</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="_or">
|
||||
<span className="font-medium">or</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Text className="p-2 !font-medium">
|
||||
{operatorDictionary[currentOperator]}
|
||||
|
||||
@@ -89,9 +89,3 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {};
|
||||
Default.parameters = defaultParameters;
|
||||
|
||||
export const DisabledOperators = Template.bind({});
|
||||
DisabledOperators.args = {
|
||||
disabledOperators: ['_in_hasura', '_nin_hasura', '_is_null'],
|
||||
};
|
||||
DisabledOperators.parameters = defaultParameters;
|
||||
|
||||
@@ -14,14 +14,11 @@ import { generateAppServiceUrl } from '@/features/projects/common/utils/generate
|
||||
import { useMemo } from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import type { RuleEditorRowProps } from './RuleEditorRow';
|
||||
import RuleEditorRow from './RuleEditorRow';
|
||||
import RuleGroupControls from './RuleGroupControls';
|
||||
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
||||
|
||||
export interface RuleGroupEditorProps
|
||||
extends BoxProps,
|
||||
Pick<RuleEditorRowProps, 'disabledOperators'> {
|
||||
export interface RuleGroupEditorProps extends BoxProps {
|
||||
/**
|
||||
* Determines whether or not the rule group editor is disabled.
|
||||
*/
|
||||
@@ -63,7 +60,6 @@ export default function RuleGroupEditor({
|
||||
name,
|
||||
className,
|
||||
disableRemove,
|
||||
disabledOperators = [],
|
||||
depth = 0,
|
||||
maxDepth,
|
||||
schema,
|
||||
@@ -115,7 +111,7 @@ export default function RuleGroupEditor({
|
||||
<Box
|
||||
{...props}
|
||||
className={twMerge(
|
||||
'rounded-lg border border-r-8 border-transparent pl-2',
|
||||
'flex min-h-44 flex-col justify-between rounded-lg border border-r-8 border-transparent pl-2',
|
||||
className,
|
||||
)}
|
||||
sx={[
|
||||
@@ -147,7 +143,6 @@ export default function RuleGroupEditor({
|
||||
name={name}
|
||||
index={ruleIndex}
|
||||
onRemove={() => removeRule(ruleIndex)}
|
||||
disabledOperators={disabledOperators}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@@ -177,7 +172,6 @@ export default function RuleGroupEditor({
|
||||
table={table}
|
||||
onRemove={() => removeGroup(ruleGroupIndex)}
|
||||
disableRemove={rules.length === 0 && groups.length === 1}
|
||||
disabledOperators={disabledOperators}
|
||||
name={`${name}.groups.${ruleGroupIndex}`}
|
||||
depth={depth + 1}
|
||||
disabled={disabled}
|
||||
@@ -247,7 +241,7 @@ export default function RuleGroupEditor({
|
||||
{onRemove && (
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
color="error"
|
||||
onClick={onRemove}
|
||||
disabled={disableRemove}
|
||||
>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||
import type {
|
||||
Rule,
|
||||
RuleGroup,
|
||||
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import { X } from 'lucide-react';
|
||||
import { useWatch } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
@@ -34,9 +33,9 @@ function RuleRemoveButton({
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
className={twMerge('h-10 !min-w-0 lg:!rounded-l-none', className)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={twMerge('h-10 !min-w-0', className)}
|
||||
disabled={
|
||||
disabled ||
|
||||
(rules.length === 1 && !groups?.length && !unsupported?.length)
|
||||
@@ -44,18 +43,8 @@ function RuleRemoveButton({
|
||||
{...props}
|
||||
aria-label="Remove Rule"
|
||||
onClick={onRemove}
|
||||
sx={
|
||||
!disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.grey[300]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<XIcon className="!h-4 !w-4" />
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
||||
import type { InputProps } from '@/components/ui/v2/Input';
|
||||
import { inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import type { ColumnAutocompleteProps } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandCreateItem,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import { FancyMultiSelect } from '@/components/ui/v3/fancy-multi-select';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
ColumnAutocomplete,
|
||||
type ColumnAutocompleteProps,
|
||||
} from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { getAllPermissionVariables } from '@/features/projects/permissions/settings/utils/getAllPermissionVariables';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||
import { CommandLoading } from 'cmdk';
|
||||
import { useState } from 'react';
|
||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||
|
||||
@@ -41,23 +65,7 @@ function ColumnSelectorInput({
|
||||
schema={schema}
|
||||
table={table}
|
||||
disableRelationships
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'lg:!rounded-none !z-10',
|
||||
sx: !disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey[300]
|
||||
: theme.palette.common.white,
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
}}
|
||||
onChange={(_event, { value }) => {
|
||||
onChange={({ value }) => {
|
||||
if (selectedTablePath === `${schema}.${table}`) {
|
||||
setValue(name, [value], { shouldDirty: true });
|
||||
return;
|
||||
@@ -75,113 +83,92 @@ export interface RuleValueInputProps {
|
||||
* Name of the parent group editor.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Class name to apply to the input wrapper.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Path of the table selected through the column input.
|
||||
*/
|
||||
selectedTablePath?: string;
|
||||
/**
|
||||
* Whether the input should be marked as invalid.
|
||||
*/
|
||||
error?: InputProps['error'];
|
||||
/**
|
||||
* Helper text to display below the input.
|
||||
*/
|
||||
helperText?: InputProps['helperText'];
|
||||
}
|
||||
|
||||
export default function RuleValueInput({
|
||||
name,
|
||||
selectedTablePath,
|
||||
error,
|
||||
helperText,
|
||||
className,
|
||||
}: RuleValueInputProps) {
|
||||
const { schema, table, disabled } = useRuleGroupEditor();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { setValue } = useFormContext();
|
||||
const { project } = useProject();
|
||||
const { setValue, control } = useFormContext();
|
||||
const inputName = `${name}.value`;
|
||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
|
||||
const sharedInputSx: InputProps['sx'] = !disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? theme.palette.grey[300]
|
||||
: theme.palette.common.white,
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
const { field } = useController({
|
||||
name: inputName,
|
||||
control,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error: customClaimsError,
|
||||
} = useGetRolesPermissionsQuery({
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !isHasuraInput || !currentProject?.id,
|
||||
const [open, setOpen] = useState(false);
|
||||
const comboboxValue = useWatch({ name: inputName });
|
||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
|
||||
const { data, loading } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
skip: !project?.id,
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
if (operator === '_is_null') {
|
||||
const defaultValue = !Array.isArray(comboboxValue) ? comboboxValue : null;
|
||||
return (
|
||||
<ControlledSelect
|
||||
<Select
|
||||
disabled={disabled}
|
||||
name={inputName}
|
||||
fullWidth
|
||||
slotProps={{
|
||||
root: {
|
||||
className: 'lg:!rounded-none h-10',
|
||||
sx: !disabled
|
||||
? {
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.grey[300]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
||||
onValueChange={(newValue: string) => {
|
||||
setValue(inputName, newValue, { shouldDirty: true });
|
||||
}}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
<Option value="true">
|
||||
<ReadOnlyToggle
|
||||
checked
|
||||
slotProps={{ label: { className: '!text-sm' } }}
|
||||
/>
|
||||
</Option>
|
||||
|
||||
<Option value="false">
|
||||
<ReadOnlyToggle
|
||||
checked={false}
|
||||
slotProps={{ label: { className: '!text-sm' } }}
|
||||
/>
|
||||
</Option>
|
||||
</ControlledSelect>
|
||||
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||
<SelectValue placeholder="Is null?" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">
|
||||
<span className="font-medium">true</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="false">
|
||||
<span className="font-medium">false</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
value: `X-Hasura-${key}`,
|
||||
label: `X-Hasura-${key}`,
|
||||
group: 'Frequently used',
|
||||
}));
|
||||
|
||||
if (operator === '_in' || operator === '_nin') {
|
||||
const defaultValue = Array.isArray(field.value) ? field.value : [];
|
||||
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
disabled={disabled}
|
||||
name={inputName}
|
||||
multiple
|
||||
freeSolo
|
||||
limitTags={3}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'lg:!rounded-none !z-10',
|
||||
sx: sharedInputSx,
|
||||
},
|
||||
paper: { className: 'hidden' },
|
||||
<FancyMultiSelect
|
||||
className={className}
|
||||
options={availableHasuraPermissionVariables}
|
||||
creatable
|
||||
defaultValue={defaultValue.map((v) => ({ value: v, label: v }))}
|
||||
onChange={(value) => {
|
||||
setValue(
|
||||
inputName,
|
||||
value.map((v) => v.value),
|
||||
{ shouldDirty: true },
|
||||
);
|
||||
}}
|
||||
options={[]}
|
||||
fullWidth
|
||||
filterSelectedOptions
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -194,71 +181,70 @@ export default function RuleValueInput({
|
||||
schema={schema}
|
||||
table={table}
|
||||
name={inputName}
|
||||
error={error}
|
||||
helperText={helperText}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||
).map(({ key }) => ({
|
||||
value: `X-Hasura-${key}`,
|
||||
label: `X-Hasura-${key}`,
|
||||
group: 'Frequently used',
|
||||
}));
|
||||
const selectedVariable = availableHasuraPermissionVariables.find(
|
||||
(variable) => variable.value === comboboxValue,
|
||||
);
|
||||
const comboboxLabel =
|
||||
selectedVariable?.label || comboboxValue || 'Select variable...';
|
||||
|
||||
return (
|
||||
<ControlledAutocomplete
|
||||
disabled={disabled}
|
||||
freeSolo={!isHasuraInput}
|
||||
autoHighlight={isHasuraInput}
|
||||
isOptionEqualToValue={(
|
||||
option,
|
||||
value: string | number | AutocompleteOption<string>,
|
||||
) => {
|
||||
if (typeof value !== 'object') {
|
||||
return option.value.toLowerCase() === value?.toString().toLowerCase();
|
||||
}
|
||||
|
||||
return option.value.toLowerCase() === value.value.toLowerCase();
|
||||
}}
|
||||
name={inputName}
|
||||
groupBy={(option) => option.group}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: 'lg:!rounded-none',
|
||||
sx: sharedInputSx,
|
||||
},
|
||||
formControl: { className: '!bg-transparent' },
|
||||
paper: { className: 'empty:border-transparent' },
|
||||
}}
|
||||
fullWidth
|
||||
loading={loading}
|
||||
loadingText={<ActivityIndicator label="Loading..." />}
|
||||
error={Boolean(customClaimsError) || error}
|
||||
helperText={customClaimsError?.message || helperText}
|
||||
options={
|
||||
isHasuraInput
|
||||
? availableHasuraPermissionVariables
|
||||
: [
|
||||
{
|
||||
value: 'X-Hasura-User-Id',
|
||||
label: 'X-Hasura-User-Id',
|
||||
group: 'Frequently used',
|
||||
},
|
||||
]
|
||||
}
|
||||
onChange={(_event, _value, reason, details) => {
|
||||
if (
|
||||
reason !== 'selectOption' &&
|
||||
details.option.value !== 'X-Hasura-User-Id'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(inputName, details.option.value, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
<span className="truncate">{comboboxLabel}</span>
|
||||
<ChevronsUpDown className="h-5 min-h-5 w-5 min-w-5 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Choose variable..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No variable found.</CommandEmpty>
|
||||
{loading && <CommandLoading>Loading...</CommandLoading>}
|
||||
<CommandGroup>
|
||||
{availableHasuraPermissionVariables.map((variable) => (
|
||||
<CommandItem
|
||||
key={variable.value}
|
||||
value={variable.value}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(inputName, currentValue, { shouldDirty: true });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{variable.label}
|
||||
<Check
|
||||
className={cn(
|
||||
'ml-auto',
|
||||
comboboxValue === variable.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandCreateItem
|
||||
onCreate={(currentValue) => {
|
||||
setValue(inputName, currentValue, { shouldDirty: true });
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -544,9 +544,7 @@ export type HasuraOperator =
|
||||
| '_eq'
|
||||
| '_neq'
|
||||
| '_in'
|
||||
| '_in_hasura'
|
||||
| '_nin'
|
||||
| '_nin_hasura'
|
||||
| '_gt'
|
||||
| '_lt'
|
||||
| '_gte'
|
||||
|
||||
@@ -202,36 +202,6 @@ test('should convert a complex permission to a rule group', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test(`should convert an _in or _nin value that do not have an array as value to _in_hasura or _nin_hasura`, () => {
|
||||
expect(
|
||||
convertToRuleGroup({ title: { _in: ['X-Hasura-Allowed-Ids'] } }),
|
||||
).toMatchObject({
|
||||
operator: '_and',
|
||||
rules: [
|
||||
{
|
||||
column: 'title',
|
||||
operator: '_in',
|
||||
value: ['X-Hasura-Allowed-Ids'],
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
convertToRuleGroup({ title: { _in: 'X-Hasura-Allowed-Ids' } }),
|
||||
).toMatchObject({
|
||||
operator: '_and',
|
||||
rules: [
|
||||
{
|
||||
column: 'title',
|
||||
operator: '_in_hasura',
|
||||
value: 'X-Hasura-Allowed-Ids',
|
||||
},
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('should transform operators and relations if the _not operator is being used', () => {
|
||||
expect(
|
||||
convertToRuleGroup({ _not: { title: { _eq: 'test' } } }),
|
||||
|
||||
@@ -52,8 +52,6 @@ const negatedValueOperatorPairs: Record<HasuraOperator, HasuraOperator> = {
|
||||
_cgte: '_clt',
|
||||
_clte: '_cgt',
|
||||
_is_null: '_is_null',
|
||||
_in_hasura: '_nin_hasura',
|
||||
_nin_hasura: '_in_hasura',
|
||||
};
|
||||
|
||||
export default function convertToRuleGroup(
|
||||
@@ -151,16 +149,14 @@ export default function convertToRuleGroup(
|
||||
(currentKey === '_in' || currentKey === '_nin') &&
|
||||
typeof value === 'string'
|
||||
) {
|
||||
const operator = currentKey === '_in' ? '_in_hasura' : '_nin_hasura';
|
||||
|
||||
return {
|
||||
operator: '_and',
|
||||
rules: [
|
||||
{
|
||||
column: previousKey,
|
||||
operator: shouldNegate
|
||||
? negatedValueOperatorPairs[operator]
|
||||
: operator,
|
||||
? negatedValueOperatorPairs[currentKey]
|
||||
: currentKey,
|
||||
value,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -19,20 +19,23 @@ export const POSTGRESQL_ERROR_CODES = {
|
||||
*
|
||||
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
|
||||
*/
|
||||
export const POSTGRESQL_NUMERIC_TYPES = [
|
||||
export const POSTGRESQL_INTEGER_TYPES = [
|
||||
'smallint',
|
||||
'integer',
|
||||
'bigint',
|
||||
'decimal',
|
||||
'numeric',
|
||||
'real',
|
||||
'double precision',
|
||||
'smallserial',
|
||||
'serial',
|
||||
'bigserial',
|
||||
'oid',
|
||||
];
|
||||
|
||||
export const POSTGRESQL_DECIMAL_TYPES = [
|
||||
'decimal',
|
||||
'numeric',
|
||||
'real',
|
||||
'double precision',
|
||||
];
|
||||
|
||||
/**
|
||||
* Character data types in PostgreSQL.
|
||||
*
|
||||
|
||||
@@ -118,8 +118,8 @@ export default function DatabaseConnectionInfo() {
|
||||
}
|
||||
|
||||
const postgresHost = generateAppServiceUrl(
|
||||
project.subdomain,
|
||||
project.region,
|
||||
project?.subdomain,
|
||||
project?.region,
|
||||
'db',
|
||||
).replace('https://', '');
|
||||
|
||||
|
||||
@@ -43,7 +43,8 @@ const validationSchema = Yup.object({
|
||||
value: Yup.string().required('Major version is a required field'),
|
||||
})
|
||||
.label('Postgres major version')
|
||||
.required(),
|
||||
.required()
|
||||
.test('not-zero', 'Invalid major version', (value) => value?.value !== '0'),
|
||||
minorVersion: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required('Minor version is a required field'),
|
||||
@@ -186,18 +187,29 @@ export default function DatabaseServiceVersionSettings() {
|
||||
shouldPoll: true,
|
||||
});
|
||||
|
||||
const showMigrateWarning =
|
||||
Number(selectedMajor) > Number(currentPostgresMajor);
|
||||
|
||||
const { state } = useAppState();
|
||||
const applicationUpdating =
|
||||
state === ApplicationStatus.Updating ||
|
||||
state === ApplicationStatus.Migrating;
|
||||
|
||||
const applicationLive = state === ApplicationStatus.Live;
|
||||
const applicationPaused = state === ApplicationStatus.Paused;
|
||||
const applicationPausing = state === ApplicationStatus.Pausing;
|
||||
|
||||
const showMigrateWarning =
|
||||
!applicationPaused &&
|
||||
!applicationPausing &&
|
||||
Number(selectedMajor) > Number(currentPostgresMajor);
|
||||
|
||||
const applicationUnhealthy =
|
||||
state !== ApplicationStatus.Live && !applicationUpdating;
|
||||
!applicationLive &&
|
||||
!applicationPaused &&
|
||||
!applicationPausing &&
|
||||
!applicationUpdating;
|
||||
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
||||
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
||||
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
||||
|
||||
const versionFieldsDisabled =
|
||||
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
||||
const saveDisabled = versionFieldsDisabled || !isDirty;
|
||||
@@ -208,7 +220,7 @@ export default function DatabaseServiceVersionSettings() {
|
||||
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
||||
|
||||
// Major version change
|
||||
if (isMajorVersionDirty) {
|
||||
if (isMajorVersionDirty && applicationLive) {
|
||||
openDialog({
|
||||
title: 'Update Postgres MAJOR version',
|
||||
component: (
|
||||
@@ -228,7 +240,7 @@ export default function DatabaseServiceVersionSettings() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Minor version change
|
||||
// Only minor version change or project is paused/pausing
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: project.id,
|
||||
@@ -338,7 +350,6 @@ export default function DatabaseServiceVersionSettings() {
|
||||
return option.value;
|
||||
}}
|
||||
showCustomOption="auto"
|
||||
isOptionEqualToValue={() => false}
|
||||
filterOptions={(options, { inputValue }) => {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched = [];
|
||||
@@ -383,12 +394,13 @@ export default function DatabaseServiceVersionSettings() {
|
||||
form.setValue('majorVersion', value);
|
||||
}
|
||||
}}
|
||||
clearOnBlur
|
||||
fullWidth
|
||||
className="lg:col-span-1"
|
||||
label="MAJOR"
|
||||
options={availableMajorVersions}
|
||||
error={!!formState.errors?.majorVersion?.value?.message}
|
||||
helperText={formState.errors?.majorVersion?.value?.message}
|
||||
error={!!formState.errors?.majorVersion?.message}
|
||||
helperText={formState.errors?.majorVersion?.message}
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
<ControlledAutocomplete
|
||||
@@ -424,12 +436,13 @@ export default function DatabaseServiceVersionSettings() {
|
||||
|
||||
return result;
|
||||
}}
|
||||
clearOnBlur
|
||||
fullWidth
|
||||
className="lg:col-span-2"
|
||||
label="MINOR"
|
||||
options={availableMinorVersions}
|
||||
error={!!formState.errors?.minorVersion?.value?.message}
|
||||
helperText={formState.errors?.minorVersion?.value?.message}
|
||||
error={!!formState.errors?.minorVersion?.message}
|
||||
helperText={formState.errors?.minorVersion?.message}
|
||||
showCustomOption="auto"
|
||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||
/>
|
||||
|
||||
@@ -5,28 +5,41 @@ import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { DatabaseStorageCapacityWarning } from '@/features/orgs/projects/database/settings/components/DatabaseStorageCapacityWarning';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
useGetPersistentVolumesEncryptedQuery,
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
capacity: Yup.number().required().min(10),
|
||||
capacity: Yup.number()
|
||||
.integer('Capacity must be an integer')
|
||||
.typeError('You must specify a number')
|
||||
.min(1, 'Capacity must be greater than 0')
|
||||
.required('Capacity is required'),
|
||||
});
|
||||
|
||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
||||
export type DatabaseStorageCapacityFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
export default function AuthDomain() {
|
||||
export default function DatabaseStorageCapacity() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { org } = useCurrentOrg();
|
||||
const { maintenanceActive } = useUI();
|
||||
@@ -48,6 +61,15 @@ export default function AuthDomain() {
|
||||
org?.plan?.featureMaxDbSize) ||
|
||||
0;
|
||||
|
||||
const { data: encryptedVolumesData } = useGetPersistentVolumesEncryptedQuery({
|
||||
variables: { appId: project?.id },
|
||||
skip: !isPlatform,
|
||||
});
|
||||
|
||||
const showEncryptionWarning = encryptedVolumesData
|
||||
? !encryptedVolumesData?.systemConfig?.persistentVolumesEncrypted
|
||||
: false;
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
@@ -58,8 +80,32 @@ export default function AuthDomain() {
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { formState, register, reset } = form;
|
||||
const { state } = useAppState();
|
||||
|
||||
const applicationPause =
|
||||
state === ApplicationStatus.Paused || state === ApplicationStatus.Pausing;
|
||||
|
||||
const { formState, register, reset, watch } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
const newCapacity = watch('capacity');
|
||||
|
||||
const decreasingSize = newCapacity < capacity;
|
||||
|
||||
const submitDisabled = useMemo(() => {
|
||||
if (!isDirty) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (maintenanceActive) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (decreasingSize && !applicationPause) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isDirty, maintenanceActive, decreasingSize, applicationPause]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !loading) {
|
||||
@@ -81,7 +127,7 @@ export default function AuthDomain() {
|
||||
throw error;
|
||||
}
|
||||
|
||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
||||
async function handleSubmit(formValues: DatabaseStorageCapacityFormValues) {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await updateConfig({
|
||||
@@ -120,7 +166,7 @@ export default function AuthDomain() {
|
||||
description="Specify the storage capacity for your PostgreSQL database."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !isDirty || maintenanceActive,
|
||||
disabled: submitDisabled,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
@@ -134,26 +180,48 @@ export default function AuthDomain() {
|
||||
{...register('capacity')}
|
||||
id="capacity"
|
||||
name="capacity"
|
||||
type="number"
|
||||
type="text"
|
||||
endAdornment={
|
||||
<InputAdornment className="absolute right-2" position="end">
|
||||
GB
|
||||
</InputAdornment>
|
||||
}
|
||||
fullWidth
|
||||
disabled={project.legacyPlan?.isFree}
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.capacity?.message)}
|
||||
helperText={formState.errors.capacity?.message}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: capacity,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!project.legacyPlan?.isFree && (
|
||||
<Alert severity="info" className="col-span-6 text-left">
|
||||
Note that volumes can only be increased (not decreased). Also, due
|
||||
to an AWS limitation, the same volume can only be increased once
|
||||
every 6 hours.
|
||||
</Alert>
|
||||
<DatabaseStorageCapacityWarning
|
||||
state={state}
|
||||
decreasingSize={decreasingSize}
|
||||
isDirty={isDirty}
|
||||
/>
|
||||
)}
|
||||
{showEncryptionWarning ? (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||
<Text className="flex items-start gap-1 font-semibold">
|
||||
Disk encryption is now available!
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text>
|
||||
To enable encryption in this project all you have to do is
|
||||
pause & unpause it in{' '}
|
||||
<Link
|
||||
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings`}
|
||||
underline="hover"
|
||||
>
|
||||
General Settings
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
|
||||
interface DatabaseStorageCapacityWarningProps {
|
||||
state: ApplicationStatus;
|
||||
decreasingSize: boolean;
|
||||
isDirty: boolean;
|
||||
}
|
||||
|
||||
export default function DatabaseStorageCapacityWarning({
|
||||
state,
|
||||
decreasingSize,
|
||||
isDirty,
|
||||
}: DatabaseStorageCapacityWarningProps) {
|
||||
const applicationPause =
|
||||
state === ApplicationStatus.Paused || state === ApplicationStatus.Pausing;
|
||||
|
||||
if (!isDirty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (state === ApplicationStatus.Live && !decreasingSize) {
|
||||
return (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||
<Text className="flex items-start gap-1 font-semibold">
|
||||
<span>⚠</span> Warning: Increasing disk size
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text>
|
||||
Due to AWS limitations, disk size can only be modified once every 6
|
||||
hours. Please ensure you increase capacity sufficiently to cover
|
||||
your needs during this period.
|
||||
</Text>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (state === ApplicationStatus.Live && decreasingSize) {
|
||||
return (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||
<Text className="flex items-start gap-1 font-semibold">
|
||||
<span>⚠</span> Warning: Decreasing disk size requires project to be
|
||||
paused first.
|
||||
</Text>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (applicationPause && decreasingSize) {
|
||||
return (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||
<Text className="flex items-start gap-1 font-semibold">
|
||||
<span>⚠</span> Warning: Ensure enough space before downsizing.
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text>
|
||||
Before downsizing, ensure enough space for your database, WAL files,
|
||||
and other supporting data to prevent issues when unpausing your
|
||||
project.
|
||||
</Text>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabaseStorageCapacityWarning } from './DatabaseStorageCapacityWarning';
|
||||
@@ -0,0 +1,5 @@
|
||||
query GetPersistentVolumesEncrypted($appId: uuid!) {
|
||||
systemConfig(appID: $appId) {
|
||||
persistentVolumesEncrypted
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default function useGetAppUsers({
|
||||
offset = 0,
|
||||
options = {},
|
||||
}: UseFilesOptions) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||
const { data, error, loading } = useRemoteAppGetUsersCustomQuery({
|
||||
...options,
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function useAppClient(
|
||||
options?: UseAppClientOptions,
|
||||
): UseAppClientReturn {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
|
||||
if (!isPlatform) {
|
||||
return new NhostClient({
|
||||
|
||||
@@ -2,21 +2,16 @@ import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
GetProjectDocument,
|
||||
useGetProjectQuery,
|
||||
type GetProjectQuery,
|
||||
type ProjectFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type Project = GetProjectQuery['apps'][0];
|
||||
|
||||
interface UseProjectOptions {
|
||||
poll?: boolean;
|
||||
target?: 'console-next' | 'user-project';
|
||||
}
|
||||
|
||||
export interface UseProjectReturnType {
|
||||
project: Project;
|
||||
loading?: boolean;
|
||||
@@ -24,10 +19,7 @@ export interface UseProjectReturnType {
|
||||
refetch: (variables?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function useProject({
|
||||
poll = false,
|
||||
target = 'console-next',
|
||||
}: UseProjectOptions = {}): UseProjectReturnType {
|
||||
export default function useProject(): UseProjectReturnType {
|
||||
const {
|
||||
query: { appSubdomain },
|
||||
isReady: isRouterReady,
|
||||
@@ -37,65 +29,36 @@ export default function useProject({
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
|
||||
const shouldFetchProject =
|
||||
isPlatform &&
|
||||
isAuthenticated &&
|
||||
!isAuthLoading &&
|
||||
!!appSubdomain &&
|
||||
isRouterReady;
|
||||
|
||||
// Fetch project data for 'console-next' target
|
||||
const {
|
||||
data: consoleData,
|
||||
loading: consoleLoading,
|
||||
error: consoleError,
|
||||
refetch: refetchConsole,
|
||||
} = useGetProjectQuery({
|
||||
variables: { subdomain: appSubdomain as string },
|
||||
skip: !shouldFetchProject && target === 'console-next',
|
||||
fetchPolicy: 'cache-and-network',
|
||||
pollInterval: poll ? 5000 * 2 : 0, // every 10s
|
||||
});
|
||||
|
||||
// Fetch project data for 'user-project' target using client.graphql
|
||||
const {
|
||||
data: userProjectData,
|
||||
isFetching: userProjectFetching,
|
||||
refetch: refetchUserProject,
|
||||
} = useQuery(
|
||||
['currentProject', appSubdomain],
|
||||
const shouldFetchProject = useMemo(
|
||||
() =>
|
||||
client.graphql.request<{ apps: ProjectFragment[] }>(GetProjectDocument, {
|
||||
isPlatform &&
|
||||
isAuthenticated &&
|
||||
!isAuthLoading &&
|
||||
!!appSubdomain &&
|
||||
isRouterReady,
|
||||
[isPlatform, isAuthenticated, isAuthLoading, appSubdomain, isRouterReady],
|
||||
);
|
||||
|
||||
const { data, isLoading, refetch, error } = useQuery(
|
||||
['project', appSubdomain as string],
|
||||
async () => {
|
||||
const response = await client.graphql.request<{
|
||||
apps: ProjectFragment[];
|
||||
}>(GetProjectDocument, {
|
||||
subdomain: (appSubdomain as string) || '',
|
||||
}),
|
||||
});
|
||||
return response;
|
||||
},
|
||||
{
|
||||
keepPreviousData: true,
|
||||
enabled: shouldFetchProject && target === 'user-project',
|
||||
staleTime: poll ? 5000 : Infinity, // Adjust staleTime for better performance
|
||||
enabled: shouldFetchProject,
|
||||
},
|
||||
);
|
||||
|
||||
const project =
|
||||
target === 'console-next'
|
||||
? consoleData?.apps?.[0] || null
|
||||
: userProjectData?.data?.apps?.[0] || null;
|
||||
|
||||
const loading =
|
||||
target === 'console-next'
|
||||
? consoleLoading || isAuthLoading
|
||||
: userProjectFetching || isAuthLoading;
|
||||
const error = consoleError
|
||||
? new Error(consoleError.message || 'Unknown error occurred.')
|
||||
: null;
|
||||
|
||||
const refetch =
|
||||
target === 'console-next' ? refetchConsole : refetchUserProject;
|
||||
|
||||
if (isPlatform) {
|
||||
return {
|
||||
project,
|
||||
loading,
|
||||
error,
|
||||
project: data?.data?.apps?.[0] || null,
|
||||
loading: isLoading && shouldFetchProject,
|
||||
error: Array.isArray(error || {}) ? error[0] : error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useProjectWithState } from './useProjectWithState';
|
||||
@@ -0,0 +1,77 @@
|
||||
import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
GetProjectStateDocument,
|
||||
type GetProjectQuery,
|
||||
type ProjectFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type Project = GetProjectQuery['apps'][0];
|
||||
|
||||
export interface UseProjectWithStateReturnType {
|
||||
project: Project;
|
||||
loading?: boolean;
|
||||
error?: Error;
|
||||
refetch: (variables?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function useProjectWithState(): UseProjectWithStateReturnType {
|
||||
const {
|
||||
query: { appSubdomain },
|
||||
isReady: isRouterReady,
|
||||
} = useRouter();
|
||||
const client = useNhostClient();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||
useAuthenticationStatus();
|
||||
|
||||
const shouldFetchProject = useMemo(
|
||||
() =>
|
||||
isPlatform &&
|
||||
isAuthenticated &&
|
||||
!isAuthLoading &&
|
||||
!!appSubdomain &&
|
||||
isRouterReady,
|
||||
[isPlatform, isAuthenticated, isAuthLoading, appSubdomain, isRouterReady],
|
||||
);
|
||||
|
||||
const { data, isLoading, refetch, error } = useQuery(
|
||||
['projectWithState', appSubdomain as string],
|
||||
async () => {
|
||||
const response = await client.graphql.request<{
|
||||
apps: ProjectFragment[];
|
||||
}>(GetProjectStateDocument, {
|
||||
subdomain: (appSubdomain as string) || '',
|
||||
});
|
||||
return response;
|
||||
},
|
||||
{
|
||||
enabled: shouldFetchProject,
|
||||
keepPreviousData: true,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 10000, // poll every 10s
|
||||
staleTime: 1000 * 60 * 5, // 1 minutes
|
||||
cacheTime: 1000 * 60 * 6, //
|
||||
},
|
||||
);
|
||||
|
||||
if (isPlatform) {
|
||||
return {
|
||||
project: data?.data?.apps?.[0] || null,
|
||||
loading: isLoading && shouldFetchProject,
|
||||
error: Array.isArray(error || {}) ? error[0] : error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
project: localApplication,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
@@ -28,7 +28,7 @@ const smtpValidationSchema = yup
|
||||
.required(),
|
||||
user: yup.string().label('Username').required(),
|
||||
password: yup.string().label('Password'),
|
||||
sender: yup.string().label('SMTP Sender').email().required(),
|
||||
sender: yup.string().label('SMTP Sender').required(),
|
||||
})
|
||||
.required();
|
||||
|
||||
|
||||
@@ -18,15 +18,19 @@ import { calculateBillableResources } from '@/features/orgs/projects/resources/s
|
||||
import type { ResourceSettingsFormValues } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { resourceSettingsValidationSchema } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/constants/common';
|
||||
import type { GetResourcesQuery } from '@/utils/__generated__/graphql';
|
||||
import type {
|
||||
ConfigConfigUpdateInput,
|
||||
GetResourcesQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetResourcesQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/constants/common';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -36,7 +40,7 @@ function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { compute, replicas, autoscaler } =
|
||||
const { compute, replicas, autoscaler, ...rest } =
|
||||
data?.config?.[service]?.resources || {};
|
||||
|
||||
return {
|
||||
@@ -44,6 +48,7 @@ function getInitialServiceResources(
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
autoscale: autoscaler || null,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,76 +181,130 @@ export default function ResourcesForm() {
|
||||
? (billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE
|
||||
: 0;
|
||||
|
||||
const getFormattedConfig = (
|
||||
values: ResourceSettingsFormValues,
|
||||
): ConfigConfigUpdateInput => {
|
||||
const sanitizedValues = removeTypename(
|
||||
values,
|
||||
) as ResourceSettingsFormValues;
|
||||
|
||||
const sanitizedInitialDatabaseResources = removeTypename(
|
||||
initialDatabaseResources,
|
||||
);
|
||||
const sanitizedInitialHasuraResources = removeTypename(
|
||||
initialHasuraResources,
|
||||
);
|
||||
const sanitizedInitialAuthResources = removeTypename(initialAuthResources);
|
||||
const sanitizedInitialStorageResources = removeTypename(
|
||||
initialStorageResources,
|
||||
);
|
||||
|
||||
if (sanitizedValues.enabled) {
|
||||
return {
|
||||
postgres: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: sanitizedValues.database.vcpu,
|
||||
memory: sanitizedValues.database.memory,
|
||||
},
|
||||
replicas: sanitizedValues.database.replicas,
|
||||
autoscaler: sanitizedValues.database.autoscale
|
||||
? {
|
||||
maxReplicas: sanitizedValues.database.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
...sanitizedInitialDatabaseResources.rest,
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: sanitizedValues.hasura.vcpu,
|
||||
memory: sanitizedValues.hasura.memory,
|
||||
},
|
||||
replicas: sanitizedValues.hasura.replicas,
|
||||
autoscaler: sanitizedValues.hasura.autoscale
|
||||
? {
|
||||
maxReplicas: sanitizedValues.hasura.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
...sanitizedInitialHasuraResources.rest,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: sanitizedValues.auth.vcpu,
|
||||
memory: sanitizedValues.auth.memory,
|
||||
},
|
||||
replicas: sanitizedValues.auth.replicas,
|
||||
autoscaler: sanitizedValues.auth.autoscale
|
||||
? {
|
||||
maxReplicas: sanitizedValues.auth.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
...sanitizedInitialAuthResources.rest,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
resources: {
|
||||
compute: {
|
||||
cpu: sanitizedValues.storage.vcpu,
|
||||
memory: sanitizedValues.storage.memory,
|
||||
},
|
||||
replicas: sanitizedValues.storage.replicas,
|
||||
autoscaler: sanitizedValues.storage.autoscale
|
||||
? {
|
||||
maxReplicas: sanitizedValues.storage.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
...sanitizedInitialStorageResources.rest,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
postgres: {
|
||||
resources: {
|
||||
compute: null,
|
||||
replicas: null,
|
||||
autoscaler: null,
|
||||
...sanitizedInitialDatabaseResources.rest,
|
||||
},
|
||||
},
|
||||
hasura: {
|
||||
resources: {
|
||||
compute: null,
|
||||
replicas: null,
|
||||
autoscaler: null,
|
||||
...sanitizedInitialHasuraResources.rest,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
resources: {
|
||||
compute: null,
|
||||
replicas: null,
|
||||
autoscaler: null,
|
||||
...sanitizedInitialAuthResources.rest,
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
resources: {
|
||||
compute: null,
|
||||
replicas: null,
|
||||
autoscaler: null,
|
||||
...sanitizedInitialStorageResources.rest,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.database.vcpu,
|
||||
memory: formValues.database.memory,
|
||||
},
|
||||
replicas: formValues.database.replicas,
|
||||
autoscaler: formValues.database.autoscale
|
||||
? {
|
||||
maxReplicas: formValues.database.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hasura: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.hasura.vcpu,
|
||||
memory: formValues.hasura.memory,
|
||||
},
|
||||
replicas: formValues.hasura.replicas,
|
||||
autoscaler: formValues.hasura.autoscale
|
||||
? {
|
||||
maxReplicas: formValues.hasura.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
auth: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.auth.vcpu,
|
||||
memory: formValues.auth.memory,
|
||||
},
|
||||
replicas: formValues.auth.replicas,
|
||||
autoscaler: formValues.auth.autoscale
|
||||
? {
|
||||
maxReplicas: formValues.auth.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
storage: {
|
||||
resources: formValues.enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.storage.vcpu,
|
||||
memory: formValues.storage.memory,
|
||||
},
|
||||
replicas: formValues.storage.replicas,
|
||||
autoscaler: formValues.storage.autoscale
|
||||
? {
|
||||
maxReplicas: formValues.storage.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
config: getFormattedConfig(formValues),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function ServiceResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid items-center justify-between grid-flow-col gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Text>
|
||||
Allocated vCPUs:{' '}
|
||||
<span className="font-medium">
|
||||
@@ -201,7 +201,7 @@ export default function ServiceResourcesFormFragment({
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid items-center justify-between grid-flow-col gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Text>
|
||||
Allocated Memory:{' '}
|
||||
<span className="font-medium">
|
||||
@@ -246,7 +246,7 @@ export default function ServiceResourcesFormFragment({
|
||||
>
|
||||
<ExclamationIcon
|
||||
color="error"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -274,7 +274,7 @@ export default function ServiceResourcesFormFragment({
|
||||
>
|
||||
<ExclamationIcon
|
||||
color="error"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
aria-hidden="false"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -306,7 +306,7 @@ export default function ServiceResourcesFormFragment({
|
||||
<Tooltip
|
||||
title={`Enable autoscaler to automatically provision extra ${title} replicas when needed.`}
|
||||
>
|
||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
||||
<InfoOutlinedIcon className="h-4 w-4" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
@@ -323,7 +323,7 @@ export default function ServiceResourcesFormFragment({
|
||||
className="font-medium"
|
||||
>
|
||||
Service Replicas
|
||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,11 @@ fragment ServiceResources on ConfigConfig {
|
||||
autoscaler {
|
||||
maxReplicas
|
||||
}
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
@@ -21,10 +26,19 @@ fragment ServiceResources on ConfigConfig {
|
||||
autoscaler {
|
||||
maxReplicas
|
||||
}
|
||||
networking {
|
||||
ingresses {
|
||||
fqdn
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
resources {
|
||||
storage {
|
||||
capacity
|
||||
}
|
||||
enablePublicAccess
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
|
||||
@@ -19,7 +19,6 @@ import { StorageFormSection } from '@/features/orgs/projects/services/components
|
||||
import { useHostName } from '@/features/projects/common/hooks/useHostName';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
validationSchema,
|
||||
@@ -29,16 +28,15 @@ import {
|
||||
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -58,9 +56,10 @@ export default function ServiceForm({
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { project } = useProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
@@ -96,14 +95,14 @@ export default function ServiceForm({
|
||||
if (serviceID) {
|
||||
return serviceID;
|
||||
}
|
||||
return uuidv4();
|
||||
return '<uuid-to-be-generated-on-creation>';
|
||||
}, [serviceID]);
|
||||
|
||||
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
||||
|
||||
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
||||
|
||||
if (initialData?.image?.startsWith(privateRegistryImage)) {
|
||||
if (initialData?.image?.startsWith(privateRegistryImage.split('/')[0])) {
|
||||
initialImageType = 'nhost';
|
||||
}
|
||||
|
||||
@@ -225,33 +224,14 @@ export default function ServiceForm({
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
object: {
|
||||
appID: project.id,
|
||||
id: newServiceID,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create service
|
||||
await insertRunServiceConfig({
|
||||
variables: {
|
||||
appID: project.id,
|
||||
serviceID: id,
|
||||
config: {
|
||||
...config,
|
||||
image: {
|
||||
// If the image field left empty then we auto-populate following this format
|
||||
// registry.<region>.<nhost_domain>/<service_id>
|
||||
image:
|
||||
values.image.length > 0
|
||||
? values.image
|
||||
: `registry.${project.region.name}.${project.region.domain}/${newServiceID}`,
|
||||
image: values.image,
|
||||
pullCredentials:
|
||||
values.pullCredentials?.length > 0
|
||||
? values.pullCredentials
|
||||
@@ -335,7 +315,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -359,7 +339,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -414,7 +394,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
name: Yup.string().required('The name is required.'),
|
||||
image: Yup.string().label('Image to run').required('The image is required.'),
|
||||
image: Yup.string()
|
||||
.trim()
|
||||
.label('Image to run')
|
||||
.required('The image is required.')
|
||||
.min(1, 'Image must be at least 1 character long'),
|
||||
pullCredentials: Yup.string().label('Pull credentials').nullable(),
|
||||
command: Yup.string(),
|
||||
environment: Yup.array().of(
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ReplicasFormSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Replicas ({replicas})
|
||||
@@ -65,7 +65,7 @@ export default function ReplicasFormSection() {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
@@ -121,7 +121,7 @@ export default function ReplicasFormSection() {
|
||||
/>
|
||||
<Text>Autoscaler</Text>
|
||||
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
||||
<InfoOutlinedIcon className="h-4 w-4" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
|
||||
export type DataGridDecimalCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, number | string>;
|
||||
|
||||
export default function DataGridDecimalCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
}: DataGridDecimalCellProps<TData>) {
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
if (typeof temporaryValue === 'string') {
|
||||
await onSave(parseFloat(temporaryValue));
|
||||
} else if (typeof temporaryValue === 'number') {
|
||||
await onSave(temporaryValue);
|
||||
} else {
|
||||
await onSave(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value ?? null);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
value={
|
||||
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||
? temporaryValue
|
||||
: ''
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (optimisticValue === null || typeof optimisticValue === 'undefined') {
|
||||
return (
|
||||
<Text className="truncate !text-xs" color="disabled">
|
||||
null
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text className="truncate !text-xs">{optimisticValue}</Text>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridDecimalCell';
|
||||
export { default as DataGridDecimalCell } from './DataGridDecimalCell';
|
||||
@@ -4,15 +4,15 @@ import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
|
||||
export type DataGridNumericCellProps<TData extends object> =
|
||||
export type DataGridIntegerCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, number>;
|
||||
|
||||
export default function DataGridNumericCell<TData extends object>({
|
||||
export default function DataGridIntegerCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
}: DataGridNumericCellProps<TData>) {
|
||||
}: DataGridIntegerCellProps<TData>) {
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridIntegerCell';
|
||||
export { default as DataGridIntegerCell } from './DataGridIntegerCell';
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridNumericCell';
|
||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
||||
@@ -166,7 +166,7 @@ export default function DataGridPreviewCell<TData extends object>({
|
||||
value: { fetchBlob, id, mimeType, alt, blob },
|
||||
fallbackPreview = null,
|
||||
}: DataGridPreviewCellProps<TData>) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
@@ -13,10 +13,10 @@ import { FilesDataGridControls } from '@/features/orgs/projects/storage/dataGrid
|
||||
import { useBuckets } from '@/features/orgs/projects/storage/dataGrid/hooks/useBuckets';
|
||||
import { useFiles } from '@/features/orgs/projects/storage/dataGrid/hooks/useFiles';
|
||||
import { useFilesAggregate } from '@/features/orgs/projects/storage/dataGrid/hooks/useFilesAggregate';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||
import type { Files } from '@/utils/__generated__/graphql';
|
||||
import { Order_By as OrderBy } from '@/utils/__generated__/graphql';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { showLoadingToast, triggerToast } from '@/utils/toast';
|
||||
import debounce from 'lodash.debounce';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent } from 'react';
|
||||
@@ -32,7 +32,7 @@ export type FilesDataGridProps = Partial<DataGridProps<StoredFile>>;
|
||||
|
||||
export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
const router = useRouter();
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const [searchString, setSearchString] = useState<string | null>(null);
|
||||
const [currentOffset, setCurrentOffset] = useState<number | null>(
|
||||
@@ -118,7 +118,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
||||
DataGridPreviewCell({
|
||||
...cellProps,
|
||||
fallbackPreview: (
|
||||
<FilePreviewIcon className="w-5 h-5 fill-current" />
|
||||
<FilePreviewIcon className="h-5 w-5 fill-current" />
|
||||
),
|
||||
}),
|
||||
minWidth: 80,
|
||||
|
||||
@@ -12,9 +12,9 @@ import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { FileUploadButtonProps } from '@/features/orgs/projects/storage/dataGrid/components/FileUploadButton';
|
||||
import { FileUploadButton } from '@/features/orgs/projects/storage/dataGrid/components/FileUploadButton';
|
||||
import type { Files } from '@/utils/__generated__/graphql';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type { Files } from '@/utils/__generated__/graphql';
|
||||
import type { PropsWithoutRef } from 'react';
|
||||
import { useState } from 'react';
|
||||
import type { Row } from 'react-table';
|
||||
@@ -38,7 +38,7 @@ export default function FilesDataGridControls({
|
||||
...props
|
||||
}: FilesDataGridControlsProps) {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const appClient = useAppClient();
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function FilesDataGridControls({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid w-full grid-cols-12 gap-2 mx-auto">
|
||||
<div className="mx-auto grid w-full grid-cols-12 gap-2">
|
||||
<Input
|
||||
className={twMerge(
|
||||
'col-span-12 xs+:col-span-12 md:col-span-9 xl:col-span-10',
|
||||
@@ -170,7 +170,7 @@ export default function FilesDataGridControls({
|
||||
{...restFilterProps}
|
||||
/>
|
||||
|
||||
<div className="grid grid-flow-col col-span-12 gap-2 md:col-span-3 xl:col-span-2">
|
||||
<div className="col-span-12 grid grid-flow-col gap-2 md:col-span-3 xl:col-span-2">
|
||||
<DataGridPagination
|
||||
className={twMerge('col-span-6', paginationClassName)}
|
||||
{...restPaginationProps}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import type {
|
||||
Files_Order_By as FilesOrderBy,
|
||||
GetFilesQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useGetFilesQuery } from '@/utils/__generated__/graphql';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
|
||||
export type UseFilesOptions = {
|
||||
@@ -38,7 +38,7 @@ export default function useFiles({
|
||||
orderBy,
|
||||
options = {},
|
||||
}: UseFilesOptions) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const { project } = useProject();
|
||||
const { data, previousData, ...rest } = useGetFilesQuery({
|
||||
variables: {
|
||||
where: searchString
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
@@ -8,10 +10,22 @@ import { useEffect } from 'react';
|
||||
export default function useNotFoundRedirect() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
query: { orgSlug, workspaceSlug, appSubdomain, updating, appSlug },
|
||||
query: {
|
||||
orgSlug: urlOrgSlug,
|
||||
workspaceSlug: urlWorkspaceSlug,
|
||||
appSubdomain: urlAppSubdomain,
|
||||
updating,
|
||||
appSlug: urlAppSlug,
|
||||
},
|
||||
isReady,
|
||||
} = router;
|
||||
|
||||
const { project, loading: projectLoading } = useProject();
|
||||
const { org, loading: orgLoading } = useCurrentOrg();
|
||||
|
||||
const { subdomain: projectSubdomain } = project || {};
|
||||
const { slug: currentOrgSlug } = org || {};
|
||||
|
||||
const { currentProject, currentWorkspace, loading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
@@ -23,18 +37,24 @@ export default function useNotFoundRedirect() {
|
||||
!isReady ||
|
||||
// If the current workspace and project are not loaded, we don't want to redirect to 404
|
||||
loading ||
|
||||
// If the project is loading, we don't want to redirect to 404
|
||||
projectLoading ||
|
||||
// If the org is loading, we don't want to redirect to 404
|
||||
orgLoading ||
|
||||
// If we're already on the 404 page, we don't want to redirect to 404
|
||||
router.pathname === '/404' ||
|
||||
router.pathname === '/' ||
|
||||
router.pathname === '/account' ||
|
||||
router.pathname === '/support/ticket' ||
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
router.pathname.includes('/orgs/_') ||
|
||||
router.pathname.includes('/orgs/_/projects/_') ||
|
||||
(urlOrgSlug === currentOrgSlug && !urlAppSubdomain) ||
|
||||
(urlOrgSlug === currentOrgSlug && urlAppSubdomain === projectSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
(workspaceSlug && currentWorkspace && appSlug && currentProject) ||
|
||||
(urlWorkspaceSlug && currentWorkspace && urlAppSlug && currentProject) ||
|
||||
// If we are on a valid workspace and no project is selected, we don't want to redirect to 404
|
||||
(workspaceSlug && currentWorkspace && !appSlug && !currentProject)
|
||||
(urlWorkspaceSlug && currentWorkspace && !urlAppSlug && !currentProject)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -45,11 +65,15 @@ export default function useNotFoundRedirect() {
|
||||
currentWorkspace,
|
||||
isReady,
|
||||
loading,
|
||||
appSubdomain,
|
||||
appSlug,
|
||||
urlAppSubdomain,
|
||||
urlAppSlug,
|
||||
router,
|
||||
updating,
|
||||
workspaceSlug,
|
||||
orgSlug,
|
||||
projectLoading,
|
||||
orgLoading,
|
||||
currentOrgSlug,
|
||||
projectSubdomain,
|
||||
urlWorkspaceSlug,
|
||||
urlOrgSlug,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -28,16 +28,15 @@ import {
|
||||
type ServiceFormValues,
|
||||
} from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { removeTypename } from '@/utils/helpers';
|
||||
import {
|
||||
useInsertRunServiceConfigMutation,
|
||||
useInsertRunServiceMutation,
|
||||
useReplaceRunServiceConfigMutation,
|
||||
type ConfigRunServiceConfigInsertInput,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
@@ -57,7 +56,6 @@ export default function ServiceForm({
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||
const [insertRunService] = useInsertRunServiceMutation();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||
@@ -187,20 +185,11 @@ export default function ServiceForm({
|
||||
// Insert service config
|
||||
const {
|
||||
data: {
|
||||
insertRunService: { id: newServiceID, subdomain },
|
||||
insertRunServiceConfig: { serviceID: newServiceID },
|
||||
},
|
||||
} = await insertRunService({
|
||||
variables: {
|
||||
object: {
|
||||
appID: currentProject.id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await insertRunServiceConfig({
|
||||
} = await insertRunServiceConfig({
|
||||
variables: {
|
||||
appID: currentProject.id,
|
||||
serviceID: newServiceID,
|
||||
config: {
|
||||
...config,
|
||||
image: {
|
||||
@@ -209,14 +198,14 @@ export default function ServiceForm({
|
||||
image:
|
||||
values.image.length > 0
|
||||
? values.image
|
||||
: `registry.${currentProject.region.name}.${currentProject.region.domain}/${newServiceID}`,
|
||||
: `registry.${currentProject.region.name}.${currentProject.region.domain}/<uuid-to-be-generated-on-creation>`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
setDetailsServiceId(newServiceID);
|
||||
setDetailsServiceSubdomain(subdomain);
|
||||
setDetailsServiceSubdomain('');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -322,7 +311,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -362,7 +351,7 @@ export default function ServiceForm({
|
||||
>
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -393,7 +382,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="w-4 h-4"
|
||||
className="h-4 w-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -441,7 +430,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
mutation insertRunService($object: run_service_insert_input!) {
|
||||
insertRunService(object: $object) {
|
||||
id
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
mutation insertRunServiceConfig(
|
||||
mutation InsertRunServiceConfig(
|
||||
$appID: uuid!
|
||||
$serviceID: uuid!
|
||||
$config: ConfigRunServiceConfigInsertInput!
|
||||
) {
|
||||
insertRunServiceConfig(
|
||||
appID: $appID
|
||||
serviceID: $serviceID
|
||||
config: $config
|
||||
) {
|
||||
name
|
||||
insertRunServiceConfig(appID: $appID, config: $config) {
|
||||
serviceID
|
||||
config {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function AutoEmbeddingsPage() {
|
||||
}
|
||||
|
||||
if (
|
||||
(isPlatform && !org?.plan?.isFree && !project.config?.ai) ||
|
||||
(isPlatform && !org?.plan?.isFree && !project?.config?.ai) ||
|
||||
!isGraphiteEnabled
|
||||
) {
|
||||
return (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -4,7 +4,10 @@ import { Form } from '@/components/form/Form';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { TransferProject } from '@/features/orgs/components/TransferProject';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { SettingsLayout } from '@/features/orgs/layout/SettingsLayout';
|
||||
@@ -12,6 +15,7 @@ import { RemoveApplicationModal } from '@/features/orgs/projects/common/componen
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useRunServices } from '@/features/orgs/projects/common/hooks/useRunServices';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
@@ -25,7 +29,7 @@ import { ApplicationStatus } from '@/types/application';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -51,6 +55,20 @@ export default function SettingsGeneralPage() {
|
||||
const { project, loading, refetch: refetchProject } = useProject();
|
||||
const { state } = useAppState();
|
||||
|
||||
const { services } = useRunServices();
|
||||
|
||||
const showWarning = useMemo(() => {
|
||||
const isPlanFree = org?.plan?.isFree;
|
||||
|
||||
if (isPlanFree) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return services?.some(
|
||||
(service) => service?.config?.resources?.storage?.length > 0,
|
||||
);
|
||||
}, [org?.plan?.isFree, services]);
|
||||
|
||||
const [updateApp] = useUpdateApplicationMutation();
|
||||
const [deleteApplication] = useBillingDeleteAppMutation();
|
||||
const [pauseApplication, { loading: pauseApplicationLoading }] =
|
||||
@@ -242,9 +260,49 @@ export default function SettingsGeneralPage() {
|
||||
onClick: () => {
|
||||
openAlertDialog({
|
||||
title: 'Pause Project?',
|
||||
payload:
|
||||
'Are you sure you want to pause this project? It will not be accessible until you unpause it.',
|
||||
payload: (
|
||||
<div className="flex flex-col gap-2">
|
||||
{showWarning ? (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="flex flex-col gap-3 text-left"
|
||||
>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||
<Text className="flex items-start gap-1 font-semibold">
|
||||
<span>⚠</span> Warning: This action will delete
|
||||
all volume data for your Run services.
|
||||
</Text>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Text>
|
||||
Pausing this project will delete all persistent
|
||||
volume data for your Run services. No automatic
|
||||
backups are made. Please backup your data
|
||||
manually to prevent loss. Contact{' '}
|
||||
<Link
|
||||
href="/support"
|
||||
target="_blank"
|
||||
className="underline"
|
||||
sx={{
|
||||
color: 'text.primary',
|
||||
}}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
support
|
||||
</Link>{' '}
|
||||
with any questions.
|
||||
</Text>
|
||||
</div>
|
||||
</Alert>
|
||||
) : null}
|
||||
<p className="text-pretty">
|
||||
Are you sure you want to pause this project? It will
|
||||
not be accessible until you unpause it.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
props: {
|
||||
maxWidth: 'sm',
|
||||
onPrimaryAction: handlePauseApplication,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -19,7 +19,7 @@ const validationSchema = Yup.object({
|
||||
email: Yup.string().label('Email').email().required(),
|
||||
});
|
||||
|
||||
export type ResetPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
export type NewPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
backgroundColor: 'transparent',
|
||||
@@ -28,10 +28,10 @@ const StyledInput = styled(Input)({
|
||||
},
|
||||
});
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
export default function NewPasswordPage() {
|
||||
const { resetPassword, error, isSent } = useResetPassword();
|
||||
|
||||
const form = useForm<ResetPasswordFormValues>({
|
||||
const form = useForm<NewPasswordFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
@@ -52,9 +52,11 @@ export default function ResetPasswordPage() {
|
||||
);
|
||||
}, [error]);
|
||||
|
||||
async function handleSubmit({ email }: ResetPasswordFormValues) {
|
||||
async function handleSubmit({ email }: NewPasswordFormValues) {
|
||||
try {
|
||||
await resetPassword(email);
|
||||
await resetPassword(email, {
|
||||
redirectTo: '/password/reset',
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while signing up. Please try again.',
|
||||
@@ -124,8 +126,10 @@ export default function ResetPasswordPage() {
|
||||
);
|
||||
}
|
||||
|
||||
ResetPasswordPage.getLayout = function getLayout(page: ReactElement) {
|
||||
NewPasswordPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<UnauthenticatedLayout title="Reset Password">{page}</UnauthenticatedLayout>
|
||||
<UnauthenticatedLayout title="Request Password Reset">
|
||||
{page}
|
||||
</UnauthenticatedLayout>
|
||||
);
|
||||
};
|
||||
144
dashboard/src/pages/password/reset.tsx
Normal file
144
dashboard/src/pages/password/reset.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { UnauthenticatedLayout } from '@/components/layout/UnauthenticatedLayout';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { styled } from '@mui/material';
|
||||
import { useChangePassword } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactElement } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
const validationSchema = Yup.object({
|
||||
newPassword: Yup.string()
|
||||
.label('New Password')
|
||||
.required('New Password is required'),
|
||||
confirmNewPassword: Yup.string()
|
||||
.label('Confirm New Password')
|
||||
.required('Confirm New Password is required')
|
||||
.oneOf([Yup.ref('newPassword')], 'Passwords must match'),
|
||||
});
|
||||
|
||||
export type ResetPasswordFormValues = Yup.InferType<typeof validationSchema>;
|
||||
|
||||
const StyledInput = styled(Input)({
|
||||
backgroundColor: 'transparent',
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent !important',
|
||||
},
|
||||
});
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const router = useRouter();
|
||||
const { changePassword } = useChangePassword();
|
||||
|
||||
const form = useForm<ResetPasswordFormValues>({
|
||||
reValidateMode: 'onSubmit',
|
||||
defaultValues: {
|
||||
newPassword: '',
|
||||
confirmNewPassword: '',
|
||||
},
|
||||
resolver: yupResolver(validationSchema),
|
||||
});
|
||||
|
||||
const { register, formState } = form;
|
||||
|
||||
async function handleSubmit({ newPassword }: ResetPasswordFormValues) {
|
||||
try {
|
||||
const password = newPassword;
|
||||
|
||||
const { isError, error } = await changePassword(password);
|
||||
|
||||
if (isError) {
|
||||
toast.error(
|
||||
`An error occurred while changing your password: ${error.message}`,
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success('Password was updated successfully.');
|
||||
router.push('/');
|
||||
} catch {
|
||||
toast.error(
|
||||
'An error occurred while updating your password. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text
|
||||
variant="h2"
|
||||
component="h1"
|
||||
className="text-center text-3.5xl font-semibold lg:text-4.5xl"
|
||||
>
|
||||
Change password
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
className="grid grid-flow-row gap-4 bg-transparent"
|
||||
>
|
||||
<StyledInput
|
||||
{...register('newPassword')}
|
||||
type="password"
|
||||
id="newPassword"
|
||||
label="New Password"
|
||||
fullWidth
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
error={!!formState.errors.newPassword}
|
||||
helperText={formState.errors.newPassword?.message}
|
||||
/>
|
||||
|
||||
<StyledInput
|
||||
{...register('confirmNewPassword')}
|
||||
type="password"
|
||||
id="confirmNewPassword"
|
||||
label="Confirm New Password"
|
||||
fullWidth
|
||||
inputProps={{ min: 2, max: 128 }}
|
||||
error={!!formState.errors.confirmNewPassword}
|
||||
helperText={formState.errors.confirmNewPassword?.message}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="!bg-white !text-black disabled:!text-black disabled:!text-opacity-60"
|
||||
size="large"
|
||||
type="submit"
|
||||
disabled={formState.isSubmitting}
|
||||
loading={formState.isSubmitting}
|
||||
>
|
||||
Change password
|
||||
</Button>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
</Box>
|
||||
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
Go back to{' '}
|
||||
<NavLink href="/signin/email" color="white" className="font-medium">
|
||||
Sign In
|
||||
</NavLink>
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ResetPasswordPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<UnauthenticatedLayout title="Request Password Reset">
|
||||
{page}
|
||||
</UnauthenticatedLayout>
|
||||
);
|
||||
};
|
||||
@@ -85,7 +85,7 @@ export default function EmailSignUpPage() {
|
||||
Sign In
|
||||
</Text>
|
||||
|
||||
<Box className="grid grid-flow-row gap-4 p-6 bg-transparent border rounded-md lg:p-12">
|
||||
<Box className="grid grid-flow-row gap-4 rounded-md border bg-transparent p-6 lg:p-12">
|
||||
<FormProvider {...form}>
|
||||
<Form
|
||||
onSubmit={handleSubmit}
|
||||
@@ -123,9 +123,9 @@ export default function EmailSignUpPage() {
|
||||
/>
|
||||
|
||||
<NavLink
|
||||
href="/reset-password"
|
||||
href="/password/new"
|
||||
color="white"
|
||||
className="font-semibold justify-self-start"
|
||||
className="justify-self-start font-semibold"
|
||||
>
|
||||
Forgot password?
|
||||
</NavLink>
|
||||
@@ -150,7 +150,7 @@ export default function EmailSignUpPage() {
|
||||
</FormProvider>
|
||||
</Box>
|
||||
|
||||
<Text color="secondary" className="text-base text-center lg:text-lg">
|
||||
<Text color="secondary" className="text-center text-base lg:text-lg">
|
||||
Don't have an account?{' '}
|
||||
<NavLink href="/signup" color="white">
|
||||
Sign Up
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
722
dashboard/src/utils/__generated__/graphql.ts
generated
722
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,28 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 04d2ce1: feat: add reference documentation for signin security key
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1fa6cc4: chore: added docs for pg_jsonschema
|
||||
|
||||
## 2.25.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 46fc520: chore: add support to next.js 15, update quickstart template commands in docs
|
||||
- cdf6776: fix: update links to create new project in dashboard
|
||||
|
||||
## 2.24.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a99f034: chore: fix function name
|
||||
|
||||
## 2.23.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -44,6 +44,7 @@ In the table below you can find a list of available extensions with Nhost Postgr
|
||||
| pg_freespacemap | 1.2 | examine the free space map (FSM) |
|
||||
| pg_hashids | 1.3 | pg_hashids |
|
||||
| pg_ivm | 1.9 | incremental view maintenance on PostgreSQL |
|
||||
| pg_jsonschema | 0.3.3 | pg_jsonschema |
|
||||
| pg_prewarm | 1.2 | prewarm relation data |
|
||||
| pg_repack | 1.5.1 | Reorganize tables in PostgreSQL databases with minimal locks |
|
||||
| pg_squeeze | 1.7 | A tool to remove unused space from a relation. |
|
||||
@@ -75,7 +76,6 @@ In the table below you can find a list of available extensions with Nhost Postgr
|
||||
|
||||
In addition, you can find more information about some of the extensions below
|
||||
|
||||
|
||||
## hypopg
|
||||
|
||||
HypoPG is a PostgreSQL extension adding support for hypothetical indexes.
|
||||
@@ -277,6 +277,30 @@ DROP EXTENSION pg_ivm;
|
||||
|
||||
- [GitHub](https://github.com/sraoss/pg_ivm)
|
||||
|
||||
## pg_jsonschema
|
||||
|
||||
pg_jsonschema is a PostgreSQL extension adding support for JSON schema validation on json and jsonb data types.
|
||||
|
||||
### Managing
|
||||
|
||||
To install the extension you can create a migration with the following contents:
|
||||
|
||||
```sql SQL
|
||||
SET ROLE postgres;
|
||||
CREATE EXTENSION pg_jsonschema;
|
||||
```
|
||||
|
||||
To uninstall it, you can use the following migration:
|
||||
|
||||
```sql SQL
|
||||
SET ROLE postgres;
|
||||
DROP EXTENSION pg_jsonschema;
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
- [GitHub](https://github.com/supabase/pg_jsonschema)
|
||||
|
||||
## pg_repack
|
||||
|
||||
pg_repack is a PostgreSQL extension which lets you remove bloat from tables and indexes, and optionally restore the physical order of clustered indexes. Unlike CLUSTER and VACUUM FULL it works online, without holding an exclusive lock on the processed tables during processing. pg_repack is efficient to boot, with performance comparable to using CLUSTER directly.
|
||||
|
||||
@@ -7,7 +7,7 @@ icon: react
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Project">
|
||||
If you haven't, please create a project through the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
If you haven't, please create a project through the [Nhost Dashboard](https://app.nhost.io).
|
||||
</Step>
|
||||
|
||||
<Step title="Setup Database">
|
||||
@@ -47,7 +47,7 @@ icon: react
|
||||
Create a Next.js application.
|
||||
|
||||
```bash Terminal
|
||||
npx create-next-app@latest --no-eslint \
|
||||
npx create-next-app@next-14 --no-eslint \
|
||||
--src-dir \
|
||||
--no-tailwind \
|
||||
--import-alias "@/*" \
|
||||
@@ -59,7 +59,7 @@ icon: react
|
||||
</Step>
|
||||
|
||||
<Step title="Install the Nhost package for Next.js">
|
||||
Navidate to the React application and install `@nhost/nextjs`.
|
||||
Navigate to the React application and install `@nhost/nextjs`.
|
||||
|
||||
```bash Terminal
|
||||
cd nhost-nextjs-quickstart && npm install @nhost/nextjs
|
||||
|
||||
@@ -20,7 +20,7 @@ icon: mobile-notch
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Nhost Project">
|
||||
Create your project through the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
Create your project through the [Nhost Dashboard](https://app.nhost.io).
|
||||
</Step>
|
||||
|
||||
<Step title="Setup Database">
|
||||
|
||||
@@ -7,7 +7,7 @@ icon: react
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Nhost Project">
|
||||
Create your project through the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
Create your project through the [Nhost Dashboard](https://app.nhost.io).
|
||||
</Step>
|
||||
|
||||
<Step title="Setup Database">
|
||||
|
||||
@@ -7,7 +7,7 @@ icon: vuejs
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Project">
|
||||
If you haven't, please create a project through the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
If you haven't, please create a project through the [Nhost Dashboard](https://app.nhost.io).
|
||||
</Step>
|
||||
|
||||
<Step title="Setup Database">
|
||||
@@ -53,7 +53,7 @@ icon: vuejs
|
||||
</Step>
|
||||
|
||||
<Step title="Install the Nhost package for Vue">
|
||||
Navidate to the React application and install `@nhost/vue`.
|
||||
Navigate to the React application and install `@nhost/vue`.
|
||||
|
||||
```bash Terminal
|
||||
cd nhost-vue-quickstart && npm install @nhost/vue
|
||||
|
||||
@@ -30,7 +30,7 @@ In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
@@ -156,7 +156,7 @@ Now that we have Nhost configured, let's move on to setup the React application
|
||||
Run the following command in your terminal to create a React application using Vite.
|
||||
|
||||
```bash Terminal
|
||||
npx create-next-app@latest --no-eslint \
|
||||
npx create-next-app@next-14 --no-eslint \
|
||||
--src-dir \
|
||||
--no-tailwind \
|
||||
--import-alias "@/*" \
|
||||
|
||||
@@ -30,7 +30,7 @@ In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ In this section, you will create and setup your first Nhost project.
|
||||
|
||||
### Create project
|
||||
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io/new).
|
||||
Create a new project in the [Nhost Dashboard](https://app.nhost.io).
|
||||
|
||||
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user