Compare commits
23 Commits
@nhost/das
...
@nhost/has
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4854df4559 | ||
|
|
865dd93fbe | ||
|
|
0c50816717 | ||
|
|
d3b4fc358e | ||
|
|
b3bcacb300 | ||
|
|
aa7ecdb38f | ||
|
|
20672c7a9b | ||
|
|
29d27e19b4 | ||
|
|
46fc520707 | ||
|
|
21e90da476 | ||
|
|
b944d053d0 | ||
|
|
6902a36512 | ||
|
|
aea6d186c2 | ||
|
|
a535aa3834 | ||
|
|
c9dca09478 | ||
|
|
414896491f | ||
|
|
cdf6776523 | ||
|
|
1b40e99530 | ||
|
|
8b5c4a0951 | ||
|
|
f5594ef991 | ||
|
|
eb9556280c | ||
|
|
c87736eeeb | ||
|
|
714dffa5ec |
@@ -20,7 +20,7 @@ runs:
|
|||||||
id: pnpm-cache-dir
|
id: pnpm-cache-dir
|
||||||
shell: bash
|
shell: bash
|
||||||
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
run: echo "dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||||
- uses: actions/cache@v3
|
- uses: actions/cache@v4
|
||||||
id: pnpm-cache
|
id: pnpm-cache
|
||||||
with:
|
with:
|
||||||
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
path: ${{ steps.pnpm-cache-dir.outputs.dir }}
|
||||||
|
|||||||
2
.github/workflows/gen_ai_review.yaml
vendored
2
.github/workflows/gen_ai_review.yaml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: PR Agent action step
|
- name: PR Agent action step
|
||||||
id: pragent
|
id: pragent
|
||||||
uses: Codium-ai/pr-agent@v0.24
|
uses: Codium-ai/pr-agent@v0.26
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||||
|
|||||||
@@ -1,5 +1,43 @@
|
|||||||
# @nhost/dashboard
|
# @nhost/dashboard
|
||||||
|
|
||||||
|
## 2.13.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 21e90da: chore: remove restrictions on SMTP sender so My Name <name@acme.com> can be added
|
||||||
|
- 865dd93: fix: duplicate Run placeholders when there is an error in the backend
|
||||||
|
- 6902a36: fix: can remove resources if postgres capacity is higher than 10
|
||||||
|
- a535aa3: fix: fetch user roles locally in auth section
|
||||||
|
- 0c50816: fix: allow decimal numbers in database row insert
|
||||||
|
- aea6d18: chore: add warning when pausing a project about losing Run services persistent volume data
|
||||||
|
- d3b4fc3: feat: allow to change postgres settings if project is paused
|
||||||
|
- 29d27e1: chore: update `next` to v14.2.22 to fix vulnerabilities
|
||||||
|
- c9dca09: feat: add reset password form
|
||||||
|
- b3bcacb: fix: paused project banner cannot read null project name
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [46fc520]
|
||||||
|
- Updated dependencies [29d27e1]
|
||||||
|
- @nhost/nextjs@2.2.0
|
||||||
|
- @nhost/react-apollo@15.0.1
|
||||||
|
|
||||||
|
## 2.12.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- eb95562: fix: show all available permission variables in permission dropdown select
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 8b5c4a0: chore: cleanup layout and add disable duplicate atom key checking in development mode
|
||||||
|
|
||||||
|
## 2.11.3
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 714dffa: fix: improve project polling logic and unify usage across components
|
||||||
|
|
||||||
## 2.11.2
|
## 2.11.2
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
FROM node:18-alpine AS pruner
|
FROM node:20-alpine AS pruner
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN yarn global add turbo@1.11.3
|
RUN yarn global add turbo@2.2.3
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||||
|
|
||||||
FROM node:18-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
ARG TURBO_TOKEN
|
ARG TURBO_TOKEN
|
||||||
ARG TURBO_TEAM
|
ARG TURBO_TEAM
|
||||||
|
|
||||||
@@ -15,20 +15,20 @@ RUN apk add --no-cache libc6-compat python3 make g++
|
|||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED 1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV NEXT_PUBLIC_ENV dev
|
ENV NEXT_PUBLIC_ENV=dev
|
||||||
ENV NEXT_PUBLIC_NHOST_PLATFORM false
|
ENV NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||||
|
|
||||||
# placeholders for URLs, will be replaced on runtime by entrypoint script
|
# placeholders for URLs, will be replaced on runtime by entrypoint script
|
||||||
ENV NEXT_PUBLIC_NHOST_ADMIN_SECRET __NEXT_PUBLIC_NHOST_ADMIN_SECRET__
|
ENV NEXT_PUBLIC_NHOST_ADMIN_SECRET=__NEXT_PUBLIC_NHOST_ADMIN_SECRET__
|
||||||
ENV NEXT_PUBLIC_NHOST_AUTH_URL __NEXT_PUBLIC_NHOST_AUTH_URL__
|
ENV NEXT_PUBLIC_NHOST_AUTH_URL=__NEXT_PUBLIC_NHOST_AUTH_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_FUNCTIONS_URL __NEXT_PUBLIC_NHOST_FUNCTIONS_URL__
|
ENV NEXT_PUBLIC_NHOST_FUNCTIONS_URL=__NEXT_PUBLIC_NHOST_FUNCTIONS_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_GRAPHQL_URL __NEXT_PUBLIC_NHOST_GRAPHQL_URL__
|
ENV NEXT_PUBLIC_NHOST_GRAPHQL_URL=__NEXT_PUBLIC_NHOST_GRAPHQL_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_STORAGE_URL __NEXT_PUBLIC_NHOST_STORAGE_URL__
|
ENV NEXT_PUBLIC_NHOST_STORAGE_URL=__NEXT_PUBLIC_NHOST_STORAGE_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL __NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__
|
ENV NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=__NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL __NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
|
ENV NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL __NEXT_PUBLIC_NHOST_HASURA_API_URL__
|
ENV NEXT_PUBLIC_NHOST_HASURA_API_URL=__NEXT_PUBLIC_NHOST_HASURA_API_URL__
|
||||||
ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL __NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
||||||
|
|
||||||
RUN yarn global add pnpm@9.15.0
|
RUN yarn global add pnpm@9.15.0
|
||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
@@ -41,7 +41,7 @@ COPY turbo.json turbo.json
|
|||||||
COPY config/ config/
|
COPY config/ config/
|
||||||
RUN pnpm build:dashboard
|
RUN pnpm build:dashboard
|
||||||
|
|
||||||
FROM node:18-alpine AS runner
|
FROM node:20-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
@@ -58,4 +58,4 @@ COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/standalone/app ./
|
|||||||
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
COPY --from=builder --chown=nextjs:nodejs /app/dashboard/.next/static ./dashboard/.next/static
|
||||||
|
|
||||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
CMD ["node", "dashboard/server.js"]
|
CMD ["node", "dashboard/server.js"]
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "2.11.2",
|
"version": "2.13.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"dev": "next dev",
|
"dev": "RECOIL_DUPLICATE_ATOM_KEY_CHECKING_ENABLED=false next dev",
|
||||||
"build": "next build --no-lint",
|
"build": "next build --no-lint",
|
||||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
"just-kebab-case": "^4.2.0",
|
"just-kebab-case": "^4.2.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lucide-react": "^0.416.0",
|
"lucide-react": "^0.416.0",
|
||||||
"next": "^14.2.10",
|
"next": "^14.2.22",
|
||||||
"next-nprogress-bar": "^2.3.13",
|
"next-nprogress-bar": "^2.3.13",
|
||||||
"next-seo": "^6.5.0",
|
"next-seo": "^6.5.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
|
|||||||
@@ -21,22 +21,9 @@ import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoun
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import {
|
import { useEffect, useState } from 'react';
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
type DetailedHTMLProps,
|
|
||||||
type HTMLProps,
|
|
||||||
} from 'react';
|
|
||||||
|
|
||||||
export interface AuthenticatedLayoutProps extends BaseLayoutProps {
|
export interface AuthenticatedLayoutProps extends BaseLayoutProps {}
|
||||||
/**
|
|
||||||
* Props passed to the internal content container.
|
|
||||||
*/
|
|
||||||
contentContainerProps?: DetailedHTMLProps<
|
|
||||||
HTMLProps<HTMLDivElement>,
|
|
||||||
HTMLDivElement
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AuthenticatedLayout({
|
export default function AuthenticatedLayout({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -21,23 +21,22 @@ export default function UnauthenticatedLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||||
|
const isOnResetPassword = router.route === '/password/reset';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isPlatform || (!isLoading && isAuthenticated)) {
|
if (!isPlatform || (!isLoading && isAuthenticated)) {
|
||||||
router.push('/');
|
// we do not want to redirect if the user tries to reset their password
|
||||||
|
if (!isOnResetPassword) {
|
||||||
|
router.push('/');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
}, [isLoading, isAuthenticated, router, isPlatform, isOnResetPassword]);
|
||||||
|
|
||||||
if (!isPlatform || isLoading || isAuthenticated) {
|
if ((!isPlatform || isLoading || isAuthenticated) && !isOnResetPassword) {
|
||||||
return (
|
return (
|
||||||
<BaseLayout {...props}>
|
<BaseLayout {...props}>
|
||||||
<LoadingScreen
|
<LoadingScreen
|
||||||
sx={{ backgroundColor: (theme) => theme.palette.background.default }}
|
sx={{ backgroundColor: (theme) => theme.palette.background.default }}
|
||||||
slotProps={{
|
|
||||||
activityIndicator: {
|
|
||||||
className: 'text-white',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
);
|
);
|
||||||
@@ -59,19 +58,19 @@ export default function UnauthenticatedLayout({
|
|||||||
|
|
||||||
<RetryableErrorBoundary>
|
<RetryableErrorBoundary>
|
||||||
<Box
|
<Box
|
||||||
className="flex items-center min-h-screen"
|
className="flex min-h-screen items-center"
|
||||||
sx={{ backgroundColor: (theme) => theme.palette.common.black }}
|
sx={{ backgroundColor: (theme) => theme.palette.common.black }}
|
||||||
>
|
>
|
||||||
<Container
|
<Container
|
||||||
rootClassName="bg-transparent h-full"
|
rootClassName="bg-transparent h-full"
|
||||||
className="grid items-center w-full h-full gap-12 pt-8 pb-12 bg-transparent justify-items-center lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
||||||
>
|
>
|
||||||
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
|
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
|
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
|
||||||
<div className="absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center w-full h-full max-w-xl mx-auto overflow-hidden opacity-70">
|
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
|
||||||
<Image
|
<Image
|
||||||
priority
|
priority
|
||||||
src="/assets/line-grid.svg"
|
src="/assets/line-grid.svg"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type DialogProps } from '@radix-ui/react-dialog';
|
import { type DialogProps } from '@radix-ui/react-dialog';
|
||||||
import { Command as CommandPrimitive } from 'cmdk';
|
import { Command as CommandPrimitive, useCommandState } from 'cmdk';
|
||||||
import { Search } from 'lucide-react';
|
import { PlusIcon, Search } from 'lucide-react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { Dialog, DialogContent } from '@/components/ui/v3/dialog';
|
import { Dialog, DialogContent } from '@/components/ui/v3/dialog';
|
||||||
@@ -26,7 +26,7 @@ interface CommandDialogProps extends DialogProps {}
|
|||||||
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog {...props}>
|
<Dialog {...props}>
|
||||||
<DialogContent className="p-0 overflow-hidden shadow-lg">
|
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
{children}
|
{children}
|
||||||
</Command>
|
</Command>
|
||||||
@@ -37,14 +37,22 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
|
||||||
>(({ className, ...props }, ref) => (
|
prefix?: React.ReactNode;
|
||||||
<div className="flex items-center px-3 border-b" cmdk-input-wrapper="">
|
}
|
||||||
<Search className="w-4 h-4 mr-2 opacity-50 shrink-0" />
|
>(({ className, prefix, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
{prefix && (
|
||||||
|
<span className="pointer-events-none flex items-center text-muted-foreground">
|
||||||
|
{prefix}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<CommandPrimitive.Input
|
<CommandPrimitive.Input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-11 w-full rounded-md border-none bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-11 w-full rounded-md border-none bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
prefix && 'pl-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -73,7 +81,7 @@ const CommandEmpty = React.forwardRef<
|
|||||||
>((props, ref) => (
|
>((props, ref) => (
|
||||||
<CommandPrimitive.Empty
|
<CommandPrimitive.Empty
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="py-6 text-sm text-center"
|
className="py-6 text-center text-sm"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -140,6 +148,25 @@ const CommandShortcut = ({
|
|||||||
};
|
};
|
||||||
CommandShortcut.displayName = 'CommandShortcut';
|
CommandShortcut.displayName = 'CommandShortcut';
|
||||||
|
|
||||||
|
const CommandCreateItem = ({
|
||||||
|
onCreate,
|
||||||
|
}: {
|
||||||
|
onCreate: (value: string) => void;
|
||||||
|
}) => {
|
||||||
|
const query = useCommandState((state) => state.search);
|
||||||
|
if (!query || !onCreate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CommandItem forceMount value="create" onSelect={() => onCreate(query)}>
|
||||||
|
<PlusIcon className="mr-2" /> {query}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
CommandCreateItem.displayName = 'CommandCreateItem';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
@@ -150,4 +177,5 @@ export {
|
|||||||
CommandItem,
|
CommandItem,
|
||||||
CommandShortcut,
|
CommandShortcut,
|
||||||
CommandSeparator,
|
CommandSeparator,
|
||||||
|
CommandCreateItem,
|
||||||
};
|
};
|
||||||
|
|||||||
183
dashboard/src/components/ui/v3/fancy-multi-select.tsx
Normal file
183
dashboard/src/components/ui/v3/fancy-multi-select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/v3/badge';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/v3/command';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type KeyboardEvent,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
type Option = Record<'value' | 'label', string>;
|
||||||
|
|
||||||
|
interface FancyMultiSelectProps {
|
||||||
|
defaultValue?: Option[];
|
||||||
|
options?: Option[];
|
||||||
|
creatable?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onChange?: (selected: Option[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FancyMultiSelect({
|
||||||
|
defaultValue = [],
|
||||||
|
options = [],
|
||||||
|
creatable = false,
|
||||||
|
className,
|
||||||
|
onChange,
|
||||||
|
}: FancyMultiSelectProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Option[]>(defaultValue);
|
||||||
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
|
const handleUnselect = useCallback((option: Option) => {
|
||||||
|
setSelected((prev) => prev.filter((s) => s.value !== option.value));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
const input = inputRef.current;
|
||||||
|
if (input) {
|
||||||
|
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||||
|
if (input.value === '') {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const newSelected = [...prev];
|
||||||
|
newSelected.pop();
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// This is not a default behaviour of the <input /> field
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
input.blur();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(option: Option) => {
|
||||||
|
setInputValue('');
|
||||||
|
setSelected((prev) => {
|
||||||
|
const newSelected = [...prev, option];
|
||||||
|
onChange?.(newSelected);
|
||||||
|
return newSelected;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectables = useMemo(() => {
|
||||||
|
const filtered = options.filter(
|
||||||
|
(option) =>
|
||||||
|
!selected.map((s) => s.value).includes(option.value) &&
|
||||||
|
option.label.toLowerCase().includes(inputValue.toLowerCase()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (creatable && inputValue) {
|
||||||
|
return [
|
||||||
|
...filtered,
|
||||||
|
{
|
||||||
|
value: inputValue.toLowerCase(),
|
||||||
|
label: inputValue,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [options, selected, inputValue, creatable]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="relative overflow-visible bg-transparent"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'group flex min-h-10 flex-1 rounded-md border bg-background px-4 py-0 text-sm ring-offset-background hover:bg-accent',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-wrap items-center gap-1 overflow-x-hidden py-1">
|
||||||
|
{selected.map((option) => {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
className="h-7 overflow-x-hidden text-[12px] font-normal"
|
||||||
|
key={option.value}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<span className="overflow-x-hidden text-ellipsis whitespace-nowrap break-words font-medium">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove ${option.label}`}
|
||||||
|
className="ml-1 rounded-full outline-none"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleUnselect(option);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={() => handleUnselect(option)}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Avoid having the "Search" Icon */}
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
|
onValueChange={setInputValue}
|
||||||
|
onBlur={() => setOpen(false)}
|
||||||
|
onFocus={() => setOpen(true)}
|
||||||
|
placeholder="Select options..."
|
||||||
|
className="flex flex-1 border-none bg-transparent px-0 py-1 text-sm font-medium outline-none !ring-0 !ring-offset-0 placeholder:text-sm placeholder:text-muted-foreground group-hover:text-accent-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<CommandList>
|
||||||
|
{open && selectables.length > 0 ? (
|
||||||
|
<div className="absolute top-2 z-10 w-full rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
|
||||||
|
<CommandGroup className="h-full overflow-auto">
|
||||||
|
{selectables.map((option) => {
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onSelect={() => handleSelect(option)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{creatable &&
|
||||||
|
!options.find((opt) => opt.value === option.value)
|
||||||
|
? `Create "${option.label}"`
|
||||||
|
: option.label}
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</CommandList>
|
||||||
|
</div>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import { ApplicationUnknown } from '@/features/orgs/projects/common/components/A
|
|||||||
import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
|
import { ApplicationUnpausing } from '@/features/orgs/projects/common/components/ApplicationUnpausing';
|
||||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
||||||
import { ApplicationStatus } from '@/types/application';
|
import { ApplicationStatus } from '@/types/application';
|
||||||
import { NextSeo } from 'next-seo';
|
import { NextSeo } from 'next-seo';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -37,7 +37,7 @@ function ProjectLayoutContent({
|
|||||||
|
|
||||||
const { state } = useAppState();
|
const { state } = useAppState();
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { project, loading, error } = useProject({ poll: true });
|
const { project, loading, error } = useProjectWithState();
|
||||||
|
|
||||||
const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
|
const isOnOverviewPage = route === '/orgs/[orgSlug]/projects/[appSubdomain]';
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWith
|
|||||||
|
|
||||||
const validationSchema = yup
|
const validationSchema = yup
|
||||||
.object({
|
.object({
|
||||||
sender: yup.string().label('SMTP Sender').email().required(),
|
sender: yup.string().label('SMTP Sender').required(),
|
||||||
password: yup.string().label('Password').required(),
|
password: yup.string().label('Password').required(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const smtpValidationSchema = yup
|
|||||||
user: yup.string().label('Username').required(),
|
user: yup.string().label('Username').required(),
|
||||||
password: yup.string().label('Password'),
|
password: yup.string().label('Password'),
|
||||||
method: yup.string().required(),
|
method: yup.string().required(),
|
||||||
sender: yup.string().label('SMTP Sender').email().required(),
|
sender: yup.string().label('SMTP Sender').required(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -16,18 +16,20 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
import { EditUserPasswordForm } from '@/features/orgs/projects/authentication/users/components/EditUserPasswordForm';
|
import { EditUserPasswordForm } from '@/features/orgs/projects/authentication/users/components/EditUserPasswordForm';
|
||||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||||
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import { copy } from '@/utils/copy';
|
|
||||||
import {
|
import {
|
||||||
RemoteAppGetUsersDocument,
|
RemoteAppGetUsersDocument,
|
||||||
useGetProjectLocalesQuery,
|
useGetProjectLocalesQuery,
|
||||||
useGetRolesPermissionsQuery,
|
useGetRolesPermissionsQuery,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { copy } from '@/utils/copy';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useTheme } from '@mui/material';
|
import { useTheme } from '@mui/material';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -106,6 +108,8 @@ export default function EditUserForm({
|
|||||||
onDeleteUser,
|
onDeleteUser,
|
||||||
roles,
|
roles,
|
||||||
}: EditUserFormProps) {
|
}: EditUserFormProps) {
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const localMimirClient = useLocalMimirClient();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { onDirtyStateChange, openDialog } = useDialog();
|
const { onDirtyStateChange, openDialog } = useDialog();
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
@@ -196,6 +200,7 @@ export default function EditUserForm({
|
|||||||
|
|
||||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||||
variables: { appId: project?.id },
|
variables: { appId: project?.id },
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const allAvailableProjectRoles = getUserRoles(
|
const allAvailableProjectRoles = getUserRoles(
|
||||||
@@ -206,6 +211,7 @@ export default function EditUserForm({
|
|||||||
variables: {
|
variables: {
|
||||||
appId: project?.id,
|
appId: project?.id,
|
||||||
},
|
},
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
const allowedLocales = data?.config?.auth?.user?.locale?.allowed || [];
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||||
@@ -61,6 +63,8 @@ export interface UsersBodyProps {
|
|||||||
|
|
||||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const localMimirClient = useLocalMimirClient();
|
||||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
@@ -88,6 +92,7 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
|||||||
*/
|
*/
|
||||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||||
variables: { appId: project?.id },
|
variables: { appId: project?.id },
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ export default function ApplicationPaused() {
|
|||||||
>
|
>
|
||||||
<RemoveApplicationModal
|
<RemoveApplicationModal
|
||||||
close={() => setShowDeletingModal(false)}
|
close={() => setShowDeletingModal(false)}
|
||||||
title={`Remove project ${project.name}?`}
|
title={`Remove project ${project?.name}?`}
|
||||||
description={`The project ${project.name} will be removed. All data will be lost and there will be no way to
|
description={`The project ${project?.name} will be removed. All data will be lost and there will be no way to
|
||||||
recover the app once it has been deleted.`}
|
recover the app once it has been deleted.`}
|
||||||
className="z-50"
|
className="z-50"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProjectWithState } from '@/features/orgs/projects/hooks/useProjectWithState';
|
||||||
import { ApplicationStatus } from '@/types/application';
|
import { ApplicationStatus } from '@/types/application';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +9,7 @@ export default function useAppState(): {
|
|||||||
state: ApplicationStatus;
|
state: ApplicationStatus;
|
||||||
message?: string;
|
message?: string;
|
||||||
} {
|
} {
|
||||||
const { project } = useProject({ poll: true });
|
const { project } = useProjectWithState();
|
||||||
const noApplication = !project;
|
const noApplication = !project;
|
||||||
|
|
||||||
if (noApplication) {
|
if (noApplication) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function useProjectRedirectWhenReady(
|
|||||||
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
const { data, client, startPolling, ...rest } = useGetApplicationStateQuery({
|
||||||
...options,
|
...options,
|
||||||
variables: { ...options.variables, appId: project?.id },
|
variables: { ...options.variables, appId: project?.id },
|
||||||
skip: !project.id,
|
skip: !project?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
|||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...args}
|
{...args}
|
||||||
name="firstReference"
|
name="firstReference"
|
||||||
label="First Reference"
|
onChange={(newValue) =>
|
||||||
onChange={(_event, newValue) =>
|
|
||||||
form.setValue('firstReference', newValue.value, {
|
form.setValue('firstReference', newValue.value, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
})
|
})
|
||||||
@@ -80,8 +79,7 @@ const Template: ComponentStory<typeof ColumnAutocomplete> = function Template(
|
|||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...args}
|
{...args}
|
||||||
name="secondReference"
|
name="secondReference"
|
||||||
label="Second Reference"
|
onChange={(newValue) =>
|
||||||
onChange={(_event, newValue) =>
|
|
||||||
form.setValue('secondReference', newValue.value, {
|
form.setValue('secondReference', newValue.value, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { setupServer } from 'msw/node';
|
|||||||
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
import { afterAll, afterEach, beforeAll, test, vi } from 'vitest';
|
||||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||||
|
|
||||||
|
vi.mock('@/lib/utils', () => ({
|
||||||
|
cn: (...classes: (string | undefined)[]) => classes.filter(Boolean).join(' '),
|
||||||
|
}));
|
||||||
|
|
||||||
const server = setupServer(
|
const server = setupServer(
|
||||||
tableQuery,
|
tableQuery,
|
||||||
hasuraMetadataQuery,
|
hasuraMetadataQuery,
|
||||||
@@ -21,17 +25,9 @@ afterAll(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should render a combobox', () => {
|
test('should render a combobox', () => {
|
||||||
render(
|
render(<ColumnAutocomplete schema="public" table="books" />);
|
||||||
<ColumnAutocomplete
|
|
||||||
schema="public"
|
|
||||||
table="books"
|
|
||||||
label="Column Autocomplete"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||||
screen.getByRole('combobox', { name: /column autocomplete/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: Network requests don't go through in tests, so we can't test the
|
// Note: Network requests don't go through in tests, so we can't test the
|
||||||
|
|||||||
@@ -1,39 +1,33 @@
|
|||||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import {
|
||||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
Command,
|
||||||
import { AutocompletePopper } from '@/components/ui/v2/Autocomplete';
|
CommandEmpty,
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
CommandGroup,
|
||||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
CommandInput,
|
||||||
import { ArrowLeftIcon } from '@/components/ui/v2/icons/ArrowLeftIcon';
|
CommandItem,
|
||||||
import type { InputProps } from '@/components/ui/v2/Input';
|
CommandList,
|
||||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
} from '@/components/ui/v3/command';
|
||||||
import { List } from '@/components/ui/v2/List';
|
import {
|
||||||
import { OptionBase } from '@/components/ui/v2/Option';
|
Popover,
|
||||||
import { OptionGroupBase } from '@/components/ui/v2/OptionGroup';
|
PopoverContent,
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
import { useMetadataQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useMetadataQuery';
|
import { useMetadataQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useMetadataQuery';
|
||||||
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery';
|
||||||
import { getTruncatedText } from '@/utils/getTruncatedText';
|
import { cn } from '@/lib/utils';
|
||||||
import type { AutocompleteGroupedOption } from '@mui/base/useAutocomplete';
|
import { Check, ChevronLeft, ChevronsUpDown } from 'lucide-react';
|
||||||
import { useAutocomplete } from '@mui/base/useAutocomplete';
|
|
||||||
import type { AutocompleteRenderGroupParams } from '@mui/material/Autocomplete';
|
import useRuleGroupEditor from '@/features/orgs/projects/database/dataGrid/components/RuleGroupEditor/useRuleGroupEditor';
|
||||||
import { autocompleteClasses } from '@mui/material/Autocomplete';
|
import { CommandLoading } from 'cmdk';
|
||||||
import type {
|
import type { ForwardedRef } from 'react';
|
||||||
ChangeEvent,
|
|
||||||
ForwardedRef,
|
|
||||||
HTMLAttributes,
|
|
||||||
PropsWithoutRef,
|
|
||||||
SyntheticEvent,
|
|
||||||
} from 'react';
|
|
||||||
import { forwardRef, useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useState } from 'react';
|
||||||
import { twMerge } from 'tailwind-merge';
|
|
||||||
import type { UseAsyncValueOptions } from './useAsyncValue';
|
import type { UseAsyncValueOptions } from './useAsyncValue';
|
||||||
import useAsyncValue from './useAsyncValue';
|
import useAsyncValue from './useAsyncValue';
|
||||||
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
||||||
import useColumnGroups from './useColumnGroups';
|
import useColumnGroups from './useColumnGroups';
|
||||||
|
|
||||||
export interface ColumnAutocompleteProps
|
export interface ColumnAutocompleteProps extends Omit<ButtonProps, 'onChange'> {
|
||||||
extends Omit<PropsWithoutRef<InputProps>, 'onChange'> {
|
value?: string;
|
||||||
/**
|
/**
|
||||||
* Schema where the `table` is located.
|
* Schema where the `table` is located.
|
||||||
*/
|
*/
|
||||||
@@ -45,70 +39,39 @@ export interface ColumnAutocompleteProps
|
|||||||
/**
|
/**
|
||||||
* Function to be called when the value changes.
|
* Function to be called when the value changes.
|
||||||
*/
|
*/
|
||||||
onChange?: (
|
onChange?: (value: {
|
||||||
event: SyntheticEvent,
|
value: string;
|
||||||
value: {
|
columnMetadata?: Record<string, any>;
|
||||||
value: string;
|
disableReset?: boolean;
|
||||||
columnMetadata?: Record<string, any>;
|
}) => void;
|
||||||
disableReset?: boolean;
|
|
||||||
},
|
|
||||||
) => void;
|
|
||||||
/**
|
/**
|
||||||
* Function to be called when the input is asynchronously initialized.
|
* Function to be called when the input is asynchronously initialized.
|
||||||
*/
|
*/
|
||||||
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
||||||
/**
|
|
||||||
* Class name to be applied to the root element.
|
|
||||||
*/
|
|
||||||
rootClassName?: string;
|
|
||||||
/**
|
/**
|
||||||
* Determines if the autocomplete should allow relationships.
|
* Determines if the autocomplete should allow relationships.
|
||||||
*/
|
*/
|
||||||
disableRelationships?: UseColumnGroupsOptions['disableRelationships'];
|
disableRelationships?: UseColumnGroupsOptions['disableRelationships'];
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroup(params: AutocompleteRenderGroupParams) {
|
|
||||||
return (
|
|
||||||
<li key={params.key}>
|
|
||||||
<OptionGroupBase>{params.group}</OptionGroupBase>
|
|
||||||
|
|
||||||
<List>{params.children}</List>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOption(
|
|
||||||
option: AutocompleteOption<string>,
|
|
||||||
optionProps: HTMLAttributes<HTMLLIElement>,
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<OptionBase
|
|
||||||
{...optionProps}
|
|
||||||
className="grid grid-flow-col items-baseline justify-start justify-items-start gap-1.5"
|
|
||||||
>
|
|
||||||
<Text component="span">{option.label}</Text>
|
|
||||||
|
|
||||||
{option.group === 'columns' && (
|
|
||||||
<InlineCode>{option.metadata?.udt_name || option.value}</InlineCode>
|
|
||||||
)}
|
|
||||||
</OptionBase>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColumnAutocomplete(
|
function ColumnAutocomplete(
|
||||||
{
|
{
|
||||||
rootClassName,
|
|
||||||
schema: defaultSchema,
|
schema: defaultSchema,
|
||||||
table: defaultTable,
|
table: defaultTable,
|
||||||
value: externalValue,
|
value: externalValue,
|
||||||
disableRelationships,
|
disableRelationships,
|
||||||
onChange,
|
onChange,
|
||||||
onInitialized,
|
onInitialized,
|
||||||
...props
|
|
||||||
}: ColumnAutocompleteProps,
|
}: ColumnAutocompleteProps,
|
||||||
ref: ForwardedRef<HTMLInputElement>,
|
ref: ForwardedRef<HTMLButtonElement>,
|
||||||
) {
|
) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
|
||||||
|
const { disabled } = useRuleGroupEditor();
|
||||||
|
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const [activeRelationship, setActiveRelationship] = useState<{
|
const [activeRelationship, setActiveRelationship] = useState<{
|
||||||
schema: string;
|
schema: string;
|
||||||
table: string;
|
table: string;
|
||||||
@@ -120,7 +83,6 @@ function ColumnAutocomplete(
|
|||||||
const {
|
const {
|
||||||
data: tableData,
|
data: tableData,
|
||||||
status: tableStatus,
|
status: tableStatus,
|
||||||
error: tableError,
|
|
||||||
isFetching: isTableFetching,
|
isFetching: isTableFetching,
|
||||||
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
||||||
schema: selectedSchema,
|
schema: selectedSchema,
|
||||||
@@ -132,7 +94,6 @@ function ColumnAutocomplete(
|
|||||||
const {
|
const {
|
||||||
data: metadata,
|
data: metadata,
|
||||||
status: metadataStatus,
|
status: metadataStatus,
|
||||||
error: metadataError,
|
|
||||||
isFetching: isMetadataFetching,
|
isFetching: isMetadataFetching,
|
||||||
} = useMetadataQuery([`default.metadata`], {
|
} = useMetadataQuery([`default.metadata`], {
|
||||||
queryOptions: { refetchOnWindowFocus: false },
|
queryOptions: { refetchOnWindowFocus: false },
|
||||||
@@ -140,8 +101,6 @@ function ColumnAutocomplete(
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
initialized,
|
initialized,
|
||||||
inputValue,
|
|
||||||
setInputValue,
|
|
||||||
selectedColumn,
|
selectedColumn,
|
||||||
setSelectedColumn,
|
setSelectedColumn,
|
||||||
selectedRelationships,
|
selectedRelationships,
|
||||||
@@ -159,57 +118,20 @@ function ColumnAutocomplete(
|
|||||||
onInitialized,
|
onInitialized,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [pages, setPages] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPages(
|
||||||
|
relationshipDotNotation ? [relationshipDotNotation?.split('.')[0]] : [],
|
||||||
|
);
|
||||||
|
}, [relationshipDotNotation]);
|
||||||
|
|
||||||
|
const activePage = pages[pages.length - 1];
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveRelationship(asyncActiveRelationship);
|
setActiveRelationship(asyncActiveRelationship);
|
||||||
}, [asyncActiveRelationship]);
|
}, [asyncActiveRelationship]);
|
||||||
|
|
||||||
function isOptionEqualToValue(
|
|
||||||
option: AutocompleteOption,
|
|
||||||
value: NonNullable<string | AutocompleteOption>,
|
|
||||||
) {
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof value === 'string') {
|
|
||||||
return option.value === value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return option.value === value.value && option.custom === value.custom;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleChange(
|
|
||||||
event: SyntheticEvent,
|
|
||||||
value: NonNullable<string | AutocompleteOption>,
|
|
||||||
) {
|
|
||||||
if (typeof value === 'string' || Array.isArray(value) || !value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('group' in value && value.group === 'columns') {
|
|
||||||
setSelectedColumn(value);
|
|
||||||
setOpen(false);
|
|
||||||
setInputValue(value.value);
|
|
||||||
|
|
||||||
onChange?.(event, {
|
|
||||||
value:
|
|
||||||
selectedRelationships.length > 0
|
|
||||||
? [relationshipDotNotation, value.value].join('.')
|
|
||||||
: value.value,
|
|
||||||
columnMetadata: value.metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputValue('');
|
|
||||||
setSelectedColumn(null);
|
|
||||||
setSelectedRelationships((currentRelationships) => [
|
|
||||||
...currentRelationships,
|
|
||||||
value.metadata?.target,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = useColumnGroups({
|
const options = useColumnGroups({
|
||||||
selectedSchema,
|
selectedSchema,
|
||||||
selectedTable,
|
selectedTable,
|
||||||
@@ -218,246 +140,214 @@ function ColumnAutocomplete(
|
|||||||
disableRelationships,
|
disableRelationships,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const handleChange = (newValue: string) => {
|
||||||
popupOpen,
|
const selectedOption = options.find((option) => option.value === newValue);
|
||||||
anchorEl,
|
|
||||||
setAnchorEl,
|
|
||||||
getRootProps,
|
|
||||||
getInputLabelProps,
|
|
||||||
getInputProps,
|
|
||||||
getListboxProps,
|
|
||||||
getOptionProps,
|
|
||||||
groupedOptions,
|
|
||||||
} = useAutocomplete({
|
|
||||||
open,
|
|
||||||
inputValue,
|
|
||||||
options,
|
|
||||||
id: props?.name,
|
|
||||||
openOnFocus: !props.disabled,
|
|
||||||
disableCloseOnSelect: true,
|
|
||||||
value: selectedColumn,
|
|
||||||
onClose: () => setOpen(false),
|
|
||||||
groupBy: (option) => option.group,
|
|
||||||
isOptionEqualToValue,
|
|
||||||
onChange: handleChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleInputValueChange(
|
if (!selectedOption) {
|
||||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
return;
|
||||||
) {
|
}
|
||||||
const { value } = event.target;
|
|
||||||
setInputValue(value);
|
|
||||||
|
|
||||||
setSelectedColumn({
|
setSelectedColumn(selectedOption);
|
||||||
value,
|
setOpen(false);
|
||||||
label: value,
|
setValue(newValue === value ? '' : newValue);
|
||||||
metadata: selectedColumn?.metadata || {
|
|
||||||
table_schema: selectedSchema,
|
|
||||||
table_name: selectedTable,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
onChange?.(event, {
|
const valueObj = {
|
||||||
value:
|
value:
|
||||||
selectedRelationships.length > 0
|
selectedRelationships.length > 0
|
||||||
? [relationshipDotNotation, value].join('.')
|
? [relationshipDotNotation, newValue].join('.')
|
||||||
: value,
|
: newValue,
|
||||||
columnMetadata: {
|
columnMetadata: selectedOption.metadata,
|
||||||
table_schema: selectedSchema,
|
};
|
||||||
table_name: selectedTable,
|
|
||||||
},
|
onChange?.(valueObj);
|
||||||
});
|
};
|
||||||
}
|
|
||||||
|
const handleRelationshipChange = (newValue: string) => {
|
||||||
|
const selectedOption = options.find((option) => option.value === newValue);
|
||||||
|
|
||||||
|
if (!selectedOption) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPages((p) => [...p, newValue]);
|
||||||
|
setSelectedColumn(null);
|
||||||
|
setSearch('');
|
||||||
|
setSelectedRelationships((currentRelationships) => [
|
||||||
|
...currentRelationships,
|
||||||
|
selectedOption.metadata?.target,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = options.filter((option) => option.group === 'columns');
|
||||||
|
const relationships = options.filter(
|
||||||
|
(option) => option.group === 'relationships',
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBackRelationship = () => {
|
||||||
|
setPages((p) => p.slice(0, -1));
|
||||||
|
setSelectedColumn(null);
|
||||||
|
setSelectedRelationships((activeRelationships) =>
|
||||||
|
activeRelationships.slice(0, -1),
|
||||||
|
);
|
||||||
|
setSearch('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonPrefix = relationshipDotNotation
|
||||||
|
? `${selectedTable}.${relationshipDotNotation}`
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<div {...getRootProps()} className={rootClassName}>
|
<PopoverTrigger asChild>
|
||||||
<Input
|
<Button
|
||||||
{...props}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
fullWidth
|
disabled={disabled}
|
||||||
slotProps={{
|
variant="outline"
|
||||||
...(props.slotProps || {}),
|
role="combobox"
|
||||||
label: getInputLabelProps(),
|
aria-expanded={open}
|
||||||
input: {
|
className="justify-between"
|
||||||
...(props.slotProps?.input || {}),
|
>
|
||||||
ref: setAnchorEl,
|
{buttonPrefix ? (
|
||||||
sx: [
|
<div className="flex flex-shrink-0 gap-0 truncate">
|
||||||
...(Array.isArray(props.slotProps?.input?.sx)
|
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||||
? props.slotProps.input.sx
|
{buttonPrefix}.
|
||||||
: [props.slotProps?.input?.sx || {}]),
|
</span>
|
||||||
{
|
{selectedColumn?.label}
|
||||||
[`& .${inputClasses.input}`]: {
|
</div>
|
||||||
backgroundColor: 'transparent',
|
) : (
|
||||||
},
|
selectedColumn?.label || 'Select a column'
|
||||||
},
|
)}
|
||||||
],
|
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
|
||||||
},
|
</Button>
|
||||||
inputRoot: {
|
</PopoverTrigger>
|
||||||
...getInputProps(),
|
<PopoverContent
|
||||||
className: twMerge(
|
side="bottom"
|
||||||
Boolean(selectedColumn) || Boolean(relationshipDotNotation)
|
align="start"
|
||||||
? '!pl-0'
|
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
: null,
|
|
||||||
props.slotProps?.inputRoot?.className,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onFocus={() => {
|
|
||||||
if (props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (props.disabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
error={Boolean(tableError || metadataError) || props.error}
|
|
||||||
helperText={
|
|
||||||
String(tableError || metadataError || '') || props.helperText
|
|
||||||
}
|
|
||||||
onChange={handleInputValueChange}
|
|
||||||
value={inputValue}
|
|
||||||
startAdornment={
|
|
||||||
selectedColumn || relationshipDotNotation ? (
|
|
||||||
<Text
|
|
||||||
component="span"
|
|
||||||
sx={{
|
|
||||||
color: props.disabled ? 'text.disabled' : 'text.primary',
|
|
||||||
}}
|
|
||||||
className="!ml-2 flex-shrink-0 truncate lg:max-w-[200px]"
|
|
||||||
>
|
|
||||||
<Text component="span" color="disabled">
|
|
||||||
{selectedTable}
|
|
||||||
</Text>
|
|
||||||
.
|
|
||||||
{relationshipDotNotation && (
|
|
||||||
<>
|
|
||||||
<span className="hidden lg:inline">
|
|
||||||
{getTruncatedText(relationshipDotNotation, 15, 'end')}.
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="inline lg:hidden">
|
|
||||||
{getTruncatedText(relationshipDotNotation, 35, 'end')}.
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
endAdornment={
|
|
||||||
tableStatus === 'loading' ||
|
|
||||||
metadataStatus === 'loading' ||
|
|
||||||
!initialized ? (
|
|
||||||
<ActivityIndicator className="mr-2" delay={500} />
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AutocompletePopper
|
|
||||||
onMouseDown={(event) => event.preventDefault()}
|
|
||||||
modifiers={[{ name: 'offset', options: { offset: [0, 10] } }]}
|
|
||||||
placement="bottom-start"
|
|
||||||
open={popupOpen}
|
|
||||||
anchorEl={anchorEl}
|
|
||||||
style={{ width: anchorEl?.parentElement?.clientWidth }}
|
|
||||||
>
|
>
|
||||||
<Box
|
<Command
|
||||||
className={autocompleteClasses.paper}
|
onKeyDown={(e) => {
|
||||||
sx={{
|
if (e.key === 'Escape' || (e.key === 'Backspace' && !search)) {
|
||||||
borderWidth: (theme) => (theme.palette.mode === 'dark' ? 1 : 0),
|
e.preventDefault();
|
||||||
borderColor: (theme) =>
|
setPages((p) => p.slice(0, -1));
|
||||||
theme.palette.mode === 'dark' ? 'grey.400' : 'none',
|
setSelectedColumn(null);
|
||||||
|
setSelectedRelationships((activeRelationships) =>
|
||||||
|
activeRelationships.slice(0, -1),
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box
|
<CommandInput
|
||||||
className="grid grid-flow-col items-center justify-start gap-2 border-b-1 px-3 py-2.5"
|
value={search}
|
||||||
sx={{ backgroundColor: 'transparent' }}
|
onValueChange={setSearch}
|
||||||
>
|
autoFocus
|
||||||
{selectedRelationships.length > 0 && (
|
placeholder=""
|
||||||
<IconButton
|
prefix={
|
||||||
variant="borderless"
|
relationshipDotNotation
|
||||||
color="secondary"
|
? `
|
||||||
onClick={(event) => {
|
${selectedTable}.${relationshipDotNotation}.`
|
||||||
event.stopPropagation();
|
: ``
|
||||||
|
}
|
||||||
setInputValue('');
|
/>
|
||||||
setSelectedColumn(null);
|
{pages?.length > 0 ? (
|
||||||
setSelectedRelationships((activeRelationships) =>
|
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
|
||||||
activeRelationships.slice(0, -1),
|
<Button
|
||||||
);
|
variant="outline"
|
||||||
}}
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={handleBackRelationship}
|
||||||
>
|
>
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
<ChevronLeft className="h-5 w-5" />
|
||||||
</IconButton>
|
</Button>
|
||||||
)}
|
<span className="py-1.5 text-sm text-muted-foreground">
|
||||||
|
{defaultTable}.{pages.join('.')}
|
||||||
<Text className="direction-rtl truncate text-left">
|
</span>
|
||||||
<Text component="span" color="disabled">
|
|
||||||
{defaultTable}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{relationshipDotNotation && (
|
|
||||||
<>
|
|
||||||
<span className="hidden lg:inline">
|
|
||||||
.{getTruncatedText(relationshipDotNotation, 20, 'start')}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="inline lg:hidden">
|
|
||||||
.{relationshipDotNotation}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{(tableStatus === 'loading' ||
|
|
||||||
metadataStatus === 'loading' ||
|
|
||||||
!initialized) && (
|
|
||||||
<div className="p-2">
|
|
||||||
<ActivityIndicator label="Loading..." />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
|
<CommandList>
|
||||||
{groupedOptions.length > 0 && (
|
{!activePage && (
|
||||||
<List
|
<>
|
||||||
{...getListboxProps()}
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
className={autocompleteClasses.listbox}
|
{tableStatus === 'loading' ||
|
||||||
>
|
metadataStatus === 'loading' ||
|
||||||
{(
|
(!initialized && <CommandLoading>Loading...</CommandLoading>)}
|
||||||
groupedOptions as AutocompleteGroupedOption<
|
<CommandGroup heading="columns">
|
||||||
(typeof options)[number]
|
{columns.map((option) => (
|
||||||
>[]
|
<CommandItem
|
||||||
).map((optionGroup) =>
|
key={option.value}
|
||||||
renderGroup({
|
value={option.value}
|
||||||
key: `${optionGroup.key}`,
|
onSelect={handleChange}
|
||||||
group: optionGroup.group,
|
className="overflow-x-hidden"
|
||||||
children: optionGroup.options.map((option, index) =>
|
>
|
||||||
renderOption(
|
<Check
|
||||||
option,
|
className={cn(
|
||||||
getOptionProps({
|
'mr-2 h-4 w-4',
|
||||||
option,
|
value === option.value ? 'opacity-100' : 'opacity-0',
|
||||||
index: optionGroup.index + index,
|
)}
|
||||||
}),
|
/>
|
||||||
),
|
<div className="flex gap-3">
|
||||||
),
|
<span className="line-clamp-2 break-all">
|
||||||
}),
|
{option.label}
|
||||||
)}
|
</span>
|
||||||
</List>
|
<div className="flex items-center">
|
||||||
)}
|
<code className="relative rounded bg-primary px-[0.2rem] font-mono text-white">
|
||||||
|
{option.metadata?.udt_name || option.value}
|
||||||
{groupedOptions.length === 0 && Boolean(anchorEl) && (
|
</code>
|
||||||
<Text className={autocompleteClasses.noOptions}>No options</Text>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</Box>
|
</CommandItem>
|
||||||
</AutocompletePopper>
|
))}
|
||||||
</>
|
</CommandGroup>
|
||||||
|
{relationships.length > 0 && !disableRelationships && (
|
||||||
|
<CommandGroup heading="relationships">
|
||||||
|
{relationships.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={handleRelationshipChange}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{activePage && (
|
||||||
|
<>
|
||||||
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
|
<CommandGroup heading="columns">
|
||||||
|
{columns.map((option) => (
|
||||||
|
<CommandItem
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
onSelect={handleChange}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
value === option.value ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<span className="line-clamp-2 break-all">
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<code className="relative rounded bg-primary px-[0.2rem] font-mono text-white">
|
||||||
|
{option.metadata?.udt_name || option.value}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ export default function useAsyncValue({
|
|||||||
onInitialized,
|
onInitialized,
|
||||||
}: UseAsyncValueOptions) {
|
}: UseAsyncValueOptions) {
|
||||||
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
||||||
const [inputValue, setInputValue] = useState('');
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
// We might not going to have the most up-to-date table data because the
|
// We might not going to have the most up-to-date table data because the
|
||||||
// relationship is loaded asynchronously, so we need to make sure we don't
|
// relationship is loaded asynchronously, so we need to make sure we don't
|
||||||
@@ -131,7 +130,6 @@ export default function useAsyncValue({
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
||||||
setInputValue(activeColumn);
|
|
||||||
}, [
|
}, [
|
||||||
remainingColumnPath,
|
remainingColumnPath,
|
||||||
isTableLoading,
|
isTableLoading,
|
||||||
@@ -287,8 +285,6 @@ export default function useAsyncValue({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
initialized,
|
initialized,
|
||||||
inputValue,
|
|
||||||
setInputValue,
|
|
||||||
activeRelationship,
|
activeRelationship,
|
||||||
selectedRelationships: initialized ? selectedRelationships : [],
|
selectedRelationships: initialized ? selectedRelationships : [],
|
||||||
selectedColumn: initialized ? selectedColumn : null,
|
selectedColumn: initialized ? selectedColumn : null,
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
|
||||||
import { DataGrid } from '@/components/dataGrid/DataGrid';
|
|
||||||
import { DataGridBooleanCell } from '@/components/dataGrid/DataGridBooleanCell';
|
|
||||||
import { DataGridDateCell } from '@/components/dataGrid/DataGridDateCell';
|
|
||||||
import { DataGridNumericCell } from '@/components/dataGrid/DataGridNumericCell';
|
|
||||||
import { DataGridTextCell } from '@/components/dataGrid/DataGridTextCell';
|
|
||||||
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
import { FormActivityIndicator } from '@/components/form/FormActivityIndicator';
|
||||||
import { InlineCode } from '@/components/presentational/InlineCode';
|
import { InlineCode } from '@/components/presentational/InlineCode';
|
||||||
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
import { KeyIcon } from '@/components/ui/v2/icons/KeyIcon';
|
||||||
@@ -23,11 +17,19 @@ import { normalizeDefaultValue } from '@/features/orgs/projects/database/dataGri
|
|||||||
import {
|
import {
|
||||||
POSTGRESQL_CHARACTER_TYPES,
|
POSTGRESQL_CHARACTER_TYPES,
|
||||||
POSTGRESQL_DATE_TIME_TYPES,
|
POSTGRESQL_DATE_TIME_TYPES,
|
||||||
|
POSTGRESQL_DECIMAL_TYPES,
|
||||||
|
POSTGRESQL_INTEGER_TYPES,
|
||||||
POSTGRESQL_JSON_TYPES,
|
POSTGRESQL_JSON_TYPES,
|
||||||
POSTGRESQL_NUMERIC_TYPES,
|
|
||||||
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
} from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
|
||||||
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
import { isSchemaLocked } from '@/features/orgs/projects/database/dataGrid/utils/schemaHelpers';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import type { DataGridProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||||
|
import { DataGrid } from '@/features/orgs/projects/storage/dataGrid/components/DataGrid';
|
||||||
|
import { DataGridBooleanCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridBooleanCell';
|
||||||
|
import { DataGridDateCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDateCell';
|
||||||
|
import { DataGridDecimalCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridDecimalCell';
|
||||||
|
import { DataGridIntegerCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridIntegerCell';
|
||||||
|
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -68,10 +70,10 @@ export function createDataGridColumn(
|
|||||||
|
|
||||||
const defaultColumnConfiguration = {
|
const defaultColumnConfiguration = {
|
||||||
Header: () => (
|
Header: () => (
|
||||||
<div className="grid items-center justify-start grid-flow-col gap-1 font-normal">
|
<div className="grid grid-flow-col items-center justify-start gap-1 font-normal">
|
||||||
{column.is_primary && <KeyIcon className="text-sm" />}
|
{column.is_primary && <KeyIcon className="text-sm" />}
|
||||||
|
|
||||||
<span className="font-bold truncate" title={column.column_name}>
|
<span className="truncate font-bold" title={column.column_name}>
|
||||||
{column.column_name}
|
{column.column_name}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -104,12 +106,21 @@ export function createDataGridColumn(
|
|||||||
foreignKeyRelation: column.foreign_key_relation,
|
foreignKeyRelation: column.foreign_key_relation,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (POSTGRESQL_NUMERIC_TYPES.includes(column.data_type)) {
|
if (POSTGRESQL_INTEGER_TYPES.includes(column.data_type)) {
|
||||||
return {
|
return {
|
||||||
...defaultColumnConfiguration,
|
...defaultColumnConfiguration,
|
||||||
type: 'number',
|
type: 'number',
|
||||||
width: 200,
|
width: 200,
|
||||||
Cell: DataGridNumericCell,
|
Cell: DataGridIntegerCell,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (POSTGRESQL_DECIMAL_TYPES.includes(column.data_type)) {
|
||||||
|
return {
|
||||||
|
...defaultColumnConfiguration,
|
||||||
|
type: 'text',
|
||||||
|
width: 200,
|
||||||
|
Cell: DataGridDecimalCell,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -209,7 +209,6 @@ export default function DatabaseRecordInputGroup({
|
|||||||
autoFocus={index === 0 && autoFocusFirstInput}
|
autoFocus={index === 0 && autoFocusFirstInput}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
label: commonLabelProps,
|
label: commonLabelProps,
|
||||||
inputRoot: { step: 1 },
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -326,10 +326,10 @@ export default function RolePermissionEditorForm({
|
|||||||
return (
|
return (
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
{error && error instanceof Error && (
|
{error && error instanceof Error && (
|
||||||
<div className="px-6 mb-4 -mt-3">
|
<div className="-mt-3 mb-4 px-6">
|
||||||
<Alert
|
<Alert
|
||||||
severity="error"
|
severity="error"
|
||||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
<strong>Error:</strong> {error.message}
|
<strong>Error:</strong> {error.message}
|
||||||
@@ -349,13 +349,13 @@ export default function RolePermissionEditorForm({
|
|||||||
|
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="flex flex-col content-between flex-auto overflow-hidden border-t-1"
|
className="flex flex-auto flex-col content-between overflow-hidden border-t-1"
|
||||||
sx={{ backgroundColor: 'background.default' }}
|
sx={{ backgroundColor: 'background.default' }}
|
||||||
>
|
>
|
||||||
<div className="grid content-start flex-auto grid-flow-row gap-6 py-4 overflow-auto">
|
<div className="grid flex-auto grid-flow-row content-start gap-6 overflow-auto py-4">
|
||||||
<PermissionSettingsSection
|
<PermissionSettingsSection
|
||||||
title="Selected role & action"
|
title="Selected role & action"
|
||||||
className="justify-between grid-flow-col"
|
className="grid-flow-col justify-between"
|
||||||
>
|
>
|
||||||
<div className="grid grid-flow-col gap-4">
|
<div className="grid grid-flow-col gap-4">
|
||||||
<Text>
|
<Text>
|
||||||
@@ -408,7 +408,7 @@ export default function RolePermissionEditorForm({
|
|||||||
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Box className="grid flex-shrink-0 gap-2 p-2 border-t-1 sm:grid-flow-col sm:justify-between">
|
<Box className="grid flex-shrink-0 gap-2 border-t-1 p-2 sm:grid-flow-col sm:justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/v3/command';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
|
import type { HasuraOperator } from '@/features/database/dataGrid/types/dataBrowser';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
|
||||||
|
const commonOperators: {
|
||||||
|
value: HasuraOperator;
|
||||||
|
label?: string;
|
||||||
|
helperText?: string;
|
||||||
|
}[] = [
|
||||||
|
{ value: '_eq', helperText: 'equal' },
|
||||||
|
{ value: '_neq', helperText: 'not equal' },
|
||||||
|
{ value: '_in', helperText: 'in (array)' },
|
||||||
|
{ value: '_nin', helperText: 'not in (array)' },
|
||||||
|
{ value: '_gt', helperText: 'greater than' },
|
||||||
|
{ value: '_lt', helperText: 'lower than' },
|
||||||
|
{ value: '_gte', helperText: 'greater than or equal' },
|
||||||
|
{ value: '_lte', helperText: 'lower than or equal' },
|
||||||
|
{ value: '_ceq', helperText: 'equal to column' },
|
||||||
|
{ value: '_cne', helperText: 'not equal to column' },
|
||||||
|
{ value: '_cgt', helperText: 'greater than column' },
|
||||||
|
{ value: '_clt', helperText: 'lower than column' },
|
||||||
|
{ value: '_cgte', helperText: 'greater than or equal to column' },
|
||||||
|
{ value: '_clte', helperText: 'lower than or equal to column' },
|
||||||
|
{ value: '_is_null', helperText: 'null' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const textSpecificOperators: typeof commonOperators = [
|
||||||
|
{ value: '_like', helperText: 'like' },
|
||||||
|
{ value: '_nlike', helperText: 'not like' },
|
||||||
|
{ value: '_ilike', helperText: 'like (case-insensitive)' },
|
||||||
|
{ value: '_nilike', helperText: 'not like (case-insensitive)' },
|
||||||
|
{ value: '_similar', helperText: 'similar' },
|
||||||
|
{ value: '_nsimilar', helperText: 'not similar' },
|
||||||
|
{ value: '_regex', helperText: 'matches regex' },
|
||||||
|
{ value: '_nregex', helperText: `doesn't match regex` },
|
||||||
|
{ value: '_iregex', helperText: 'matches case-insensitive regex' },
|
||||||
|
{ value: '_niregex', helperText: `doesn't match case-insensitive regex` },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface OperatorComboBoxProps {
|
||||||
|
name: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
selectedColumnType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OperatorComboBox({
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
selectedColumnType,
|
||||||
|
}: OperatorComboBoxProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { watch, setValue } = useFormContext();
|
||||||
|
|
||||||
|
const operator = watch(`${name}.operator`);
|
||||||
|
|
||||||
|
const availableOperators = [
|
||||||
|
...commonOperators,
|
||||||
|
...(selectedColumnType === 'text' ? textSpecificOperators : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelect = (value: string) => {
|
||||||
|
if (['_in', '_nin'].includes(value)) {
|
||||||
|
setValue(`${name}.value`, [], { shouldDirty: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(`${name}.operator`, value, { shouldDirty: true });
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="justify-between"
|
||||||
|
>
|
||||||
|
{operator ?? 'Select operator...'}
|
||||||
|
<ChevronsUpDown className="h-5 w-5 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="bottom" align="start" className="p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="Search operator..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>No operator found.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{availableOperators.map((op) => (
|
||||||
|
<CommandItem
|
||||||
|
key={op.value}
|
||||||
|
keywords={[op.helperText]}
|
||||||
|
value={op.value}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
className="flex flex-row justify-between"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row gap-2">
|
||||||
|
<span className="min-w-[9ch]">{op.value}</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{op.helperText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'ml-auto',
|
||||||
|
op.value === operator ? 'opacity-100' : 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
|
||||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||||
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
|
||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useController, useFormContext } from 'react-hook-form';
|
import { useController, useFormContext } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import OperatorComboBox from './OperatorComboBox';
|
||||||
import RuleRemoveButton from './RuleRemoveButton';
|
import RuleRemoveButton from './RuleRemoveButton';
|
||||||
import RuleValueInput from './RuleValueInput';
|
import RuleValueInput from './RuleValueInput';
|
||||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
@@ -25,69 +22,6 @@ export interface RuleEditorRowProps
|
|||||||
* Function to be called when the remove button is clicked.
|
* Function to be called when the remove button is clicked.
|
||||||
*/
|
*/
|
||||||
onRemove?: VoidFunction;
|
onRemove?: VoidFunction;
|
||||||
/**
|
|
||||||
* List of operators to be disabled for the rule editor.
|
|
||||||
*
|
|
||||||
* @default []
|
|
||||||
*/
|
|
||||||
disabledOperators?: HasuraOperator[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const commonOperators: {
|
|
||||||
value: HasuraOperator;
|
|
||||||
label?: string;
|
|
||||||
helperText?: string;
|
|
||||||
}[] = [
|
|
||||||
{ value: '_eq', helperText: 'equal' },
|
|
||||||
{ value: '_neq', helperText: 'not equal' },
|
|
||||||
{ value: '_in_hasura', label: '_in', helperText: 'in (X-Hasura-)' },
|
|
||||||
{ value: '_in', helperText: 'in (array)' },
|
|
||||||
{ value: '_nin_hasura', label: '_nin', helperText: 'not in (X-Hasura-)' },
|
|
||||||
{ value: '_nin', helperText: 'not in (array)' },
|
|
||||||
{ value: '_gt', helperText: 'greater than' },
|
|
||||||
{ value: '_lt', helperText: 'lower than' },
|
|
||||||
{ value: '_gte', helperText: 'greater than or equal' },
|
|
||||||
{ value: '_lte', helperText: 'lower than or equal' },
|
|
||||||
{ value: '_ceq', helperText: 'equal to column' },
|
|
||||||
{ value: '_cne', helperText: 'not equal to column' },
|
|
||||||
{ value: '_cgt', helperText: 'greater than column' },
|
|
||||||
{ value: '_clt', helperText: 'lower than column' },
|
|
||||||
{ value: '_cgte', helperText: 'greater than or equal to column' },
|
|
||||||
{ value: '_clte', helperText: 'lower than or equal to column' },
|
|
||||||
{ value: '_is_null', helperText: 'null' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const textSpecificOperators: typeof commonOperators = [
|
|
||||||
{ value: '_like', helperText: 'like' },
|
|
||||||
{ value: '_nlike', helperText: 'not like' },
|
|
||||||
{ value: '_ilike', helperText: 'like (case-insensitive)' },
|
|
||||||
{ value: '_nilike', helperText: 'not like (case-insensitive)' },
|
|
||||||
{ value: '_similar', helperText: 'similar' },
|
|
||||||
{ value: '_nsimilar', helperText: 'not similar' },
|
|
||||||
{ value: '_regex', helperText: 'matches regex' },
|
|
||||||
{ value: '_nregex', helperText: `doesn't match regex` },
|
|
||||||
{ value: '_iregex', helperText: 'matches case-insensitive regex' },
|
|
||||||
{ value: '_niregex', helperText: `doesn't match case-insensitive regex` },
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderOption({
|
|
||||||
value,
|
|
||||||
label,
|
|
||||||
helperText,
|
|
||||||
}: (typeof commonOperators)[number]) {
|
|
||||||
return (
|
|
||||||
<Option key={value} value={value} className="grid grid-flow-col gap-2">
|
|
||||||
<Text component="span" className="inline-block w-16">
|
|
||||||
{label || value}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{helperText && (
|
|
||||||
<Text component="span" color="disabled">
|
|
||||||
{helperText}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Option>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleEditorRow({
|
export default function RuleEditorRow({
|
||||||
@@ -95,17 +29,12 @@ export default function RuleEditorRow({
|
|||||||
index,
|
index,
|
||||||
onRemove,
|
onRemove,
|
||||||
className,
|
className,
|
||||||
disabledOperators = [],
|
|
||||||
...props
|
...props
|
||||||
}: RuleEditorRowProps) {
|
}: RuleEditorRowProps) {
|
||||||
const { schema, table, disabled } = useRuleGroupEditor();
|
const { schema, table } = useRuleGroupEditor();
|
||||||
const { control, setValue, getFieldState } = useFormContext();
|
const { control, setValue } = useFormContext();
|
||||||
const rowName = `${name}.rules.${index}`;
|
const rowName = `${name}.rules.${index}`;
|
||||||
|
|
||||||
const columnState = getFieldState(`${rowName}.column`);
|
|
||||||
const operatorState = getFieldState(`${rowName}.operator`);
|
|
||||||
const valueState = getFieldState(`${rowName}.value`);
|
|
||||||
|
|
||||||
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
||||||
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
||||||
const { field: autocompleteField } = useController({
|
const { field: autocompleteField } = useController({
|
||||||
@@ -113,48 +42,19 @@ export default function RuleEditorRow({
|
|||||||
control,
|
control,
|
||||||
});
|
});
|
||||||
|
|
||||||
const disabledOperatorMap = disabledOperators.reduce(
|
|
||||||
(map, currentOperator) => map.set(currentOperator, true),
|
|
||||||
new Map<string, boolean>(),
|
|
||||||
);
|
|
||||||
|
|
||||||
const availableOperators = [
|
|
||||||
...commonOperators.filter(({ value }) => !disabledOperatorMap.has(value)),
|
|
||||||
...(selectedColumnType === 'text'
|
|
||||||
? textSpecificOperators.filter(
|
|
||||||
({ value }) => !disabledOperatorMap.get(value),
|
|
||||||
)
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'grid grid-flow-row space-y-1 lg:max-h-10 lg:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] lg:space-y-0',
|
'flex flex-col gap-1 space-y-1 overflow-x-hidden pb-4 xl:grid xl:grid-flow-row xl:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] xl:space-y-0 xl:overflow-x-visible',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...autocompleteField}
|
{...autocompleteField}
|
||||||
disabled={disabled}
|
|
||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
rootClassName="h-10"
|
onChange={({ value, columnMetadata, disableReset }) => {
|
||||||
slotProps={{
|
|
||||||
input: {
|
|
||||||
className: 'lg:!rounded-r-none',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark' ? 'grey.300' : 'common.white',
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(columnState?.error?.message)}
|
|
||||||
onChange={(_event, { value, columnMetadata, disableReset }) => {
|
|
||||||
setSelectedTablePath(
|
setSelectedTablePath(
|
||||||
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
||||||
);
|
);
|
||||||
@@ -182,69 +82,21 @@ export default function RuleEditorRow({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<OperatorComboBox
|
||||||
<ControlledSelect
|
name={rowName}
|
||||||
disabled={disabled}
|
selectedColumnType={selectedColumnType}
|
||||||
name={`${rowName}.operator`}
|
/>
|
||||||
className="h-10"
|
|
||||||
slotProps={{
|
|
||||||
root: {
|
|
||||||
className: 'lg:!rounded-none',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
}
|
|
||||||
: {},
|
|
||||||
},
|
|
||||||
listbox: { className: 'max-h-[300px]' },
|
|
||||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
|
||||||
}}
|
|
||||||
fullWidth
|
|
||||||
error={Boolean(operatorState?.error?.message)}
|
|
||||||
onChange={(_event, value: HasuraOperator) => {
|
|
||||||
if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (value === '_in_hasura' || value === '_nin_hasura') {
|
|
||||||
setValue(`${rowName}.value`, null, {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue(`${rowName}.value`, [], { shouldDirty: true });
|
|
||||||
}}
|
|
||||||
renderValue={(option) => {
|
|
||||||
if (!option?.value) {
|
|
||||||
return <span />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.value === '_in_hasura') {
|
|
||||||
return <span>_in</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (option.value === '_nin_hasura') {
|
|
||||||
return <span>_nin</span>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return <span>{option.value}</span>;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{availableOperators.map(renderOption)}
|
|
||||||
</ControlledSelect>
|
|
||||||
|
|
||||||
<RuleValueInput
|
<RuleValueInput
|
||||||
selectedTablePath={selectedTablePath}
|
selectedTablePath={selectedTablePath}
|
||||||
name={rowName}
|
name={rowName}
|
||||||
error={Boolean(valueState?.error?.message)}
|
className="min-h-10"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<RuleRemoveButton onRemove={onRemove} name={name} disabled={disabled} />
|
<RuleRemoveButton
|
||||||
|
className="w-full xl:w-auto"
|
||||||
|
onRemove={onRemove}
|
||||||
|
name={name}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/v3/select';
|
||||||
import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
import type { RuleGroup } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
import { useWatch } from 'react-hook-form';
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
|
|
||||||
@@ -32,9 +37,11 @@ export default function RuleGroupControls({
|
|||||||
...props
|
...props
|
||||||
}: RuleGroupControlsProps) {
|
}: RuleGroupControlsProps) {
|
||||||
const { disabled } = useRuleGroupEditor();
|
const { disabled } = useRuleGroupEditor();
|
||||||
|
const inputName = `${name}.operator`;
|
||||||
const currentOperator: RuleGroup['operator'] = useWatch({
|
const currentOperator: RuleGroup['operator'] = useWatch({
|
||||||
name: `${name}.operator`,
|
name: inputName,
|
||||||
});
|
});
|
||||||
|
const { setValue } = useFormContext();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -42,24 +49,26 @@ export default function RuleGroupControls({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{showSelect ? (
|
{showSelect ? (
|
||||||
<ControlledSelect
|
<Select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={`${name}.operator`}
|
name={inputName}
|
||||||
slotProps={{
|
onValueChange={(newValue: string) => {
|
||||||
root: {
|
setValue(inputName, newValue, { shouldDirty: true });
|
||||||
sx: {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}}
|
}}
|
||||||
fullWidth
|
defaultValue={currentOperator}
|
||||||
>
|
>
|
||||||
<Option value="_and">and</Option>
|
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||||
<Option value="_or">or</Option>
|
<SelectValue />
|
||||||
</ControlledSelect>
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="_and">
|
||||||
|
<span className="font-medium">and</span>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="_or">
|
||||||
|
<span className="font-medium">or</span>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
) : (
|
) : (
|
||||||
<Text className="p-2 !font-medium">
|
<Text className="p-2 !font-medium">
|
||||||
{operatorDictionary[currentOperator]}
|
{operatorDictionary[currentOperator]}
|
||||||
|
|||||||
@@ -89,9 +89,3 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
|
|||||||
export const Default = Template.bind({});
|
export const Default = Template.bind({});
|
||||||
Default.args = {};
|
Default.args = {};
|
||||||
Default.parameters = defaultParameters;
|
Default.parameters = defaultParameters;
|
||||||
|
|
||||||
export const DisabledOperators = Template.bind({});
|
|
||||||
DisabledOperators.args = {
|
|
||||||
disabledOperators: ['_in_hasura', '_nin_hasura', '_is_null'],
|
|
||||||
};
|
|
||||||
DisabledOperators.parameters = defaultParameters;
|
|
||||||
|
|||||||
@@ -14,14 +14,11 @@ import { generateAppServiceUrl } from '@/features/projects/common/utils/generate
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { RuleEditorRowProps } from './RuleEditorRow';
|
|
||||||
import RuleEditorRow from './RuleEditorRow';
|
import RuleEditorRow from './RuleEditorRow';
|
||||||
import RuleGroupControls from './RuleGroupControls';
|
import RuleGroupControls from './RuleGroupControls';
|
||||||
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
||||||
|
|
||||||
export interface RuleGroupEditorProps
|
export interface RuleGroupEditorProps extends BoxProps {
|
||||||
extends BoxProps,
|
|
||||||
Pick<RuleEditorRowProps, 'disabledOperators'> {
|
|
||||||
/**
|
/**
|
||||||
* Determines whether or not the rule group editor is disabled.
|
* Determines whether or not the rule group editor is disabled.
|
||||||
*/
|
*/
|
||||||
@@ -63,7 +60,6 @@ export default function RuleGroupEditor({
|
|||||||
name,
|
name,
|
||||||
className,
|
className,
|
||||||
disableRemove,
|
disableRemove,
|
||||||
disabledOperators = [],
|
|
||||||
depth = 0,
|
depth = 0,
|
||||||
maxDepth,
|
maxDepth,
|
||||||
schema,
|
schema,
|
||||||
@@ -115,7 +111,7 @@ export default function RuleGroupEditor({
|
|||||||
<Box
|
<Box
|
||||||
{...props}
|
{...props}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'rounded-lg border border-r-8 border-transparent pl-2',
|
'flex min-h-44 flex-col justify-between rounded-lg border border-r-8 border-transparent pl-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
sx={[
|
sx={[
|
||||||
@@ -147,7 +143,6 @@ export default function RuleGroupEditor({
|
|||||||
name={name}
|
name={name}
|
||||||
index={ruleIndex}
|
index={ruleIndex}
|
||||||
onRemove={() => removeRule(ruleIndex)}
|
onRemove={() => removeRule(ruleIndex)}
|
||||||
disabledOperators={disabledOperators}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -177,7 +172,6 @@ export default function RuleGroupEditor({
|
|||||||
table={table}
|
table={table}
|
||||||
onRemove={() => removeGroup(ruleGroupIndex)}
|
onRemove={() => removeGroup(ruleGroupIndex)}
|
||||||
disableRemove={rules.length === 0 && groups.length === 1}
|
disableRemove={rules.length === 0 && groups.length === 1}
|
||||||
disabledOperators={disabledOperators}
|
|
||||||
name={`${name}.groups.${ruleGroupIndex}`}
|
name={`${name}.groups.${ruleGroupIndex}`}
|
||||||
depth={depth + 1}
|
depth={depth + 1}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@@ -247,7 +241,7 @@ export default function RuleGroupEditor({
|
|||||||
{onRemove && (
|
{onRemove && (
|
||||||
<Button
|
<Button
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
color="secondary"
|
color="error"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
disabled={disableRemove}
|
disabled={disableRemove}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
|
||||||
import type {
|
import type {
|
||||||
Rule,
|
Rule,
|
||||||
RuleGroup,
|
RuleGroup,
|
||||||
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
import { useWatch } from 'react-hook-form';
|
import { useWatch } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
@@ -34,9 +33,9 @@ function RuleRemoveButton({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outline"
|
||||||
color="secondary"
|
size="icon"
|
||||||
className={twMerge('h-10 !min-w-0 lg:!rounded-l-none', className)}
|
className={twMerge('h-10 !min-w-0', className)}
|
||||||
disabled={
|
disabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
(rules.length === 1 && !groups?.length && !unsupported?.length)
|
(rules.length === 1 && !groups?.length && !unsupported?.length)
|
||||||
@@ -44,18 +43,8 @@ function RuleRemoveButton({
|
|||||||
{...props}
|
{...props}
|
||||||
aria-label="Remove Rule"
|
aria-label="Remove Rule"
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
sx={
|
|
||||||
!disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<XIcon className="!h-4 !w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
|
||||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
import { Button } from '@/components/ui/v3/button';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import {
|
||||||
import type { AutocompleteOption } from '@/components/ui/v2/Autocomplete';
|
Command,
|
||||||
import type { InputProps } from '@/components/ui/v2/Input';
|
CommandCreateItem,
|
||||||
import { inputClasses } from '@/components/ui/v2/Input';
|
CommandEmpty,
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
CommandGroup,
|
||||||
import type { ColumnAutocompleteProps } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
CommandInput,
|
||||||
import { ColumnAutocomplete } from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/v3/command';
|
||||||
|
import { FancyMultiSelect } from '@/components/ui/v3/fancy-multi-select';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/v3/select';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import {
|
||||||
|
ColumnAutocomplete,
|
||||||
|
type ColumnAutocompleteProps,
|
||||||
|
} from '@/features/orgs/projects/database/dataGrid/components/ColumnAutocomplete';
|
||||||
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
import type { HasuraOperator } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
|
||||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { getAllPermissionVariables } from '@/features/projects/permissions/settings/utils/getAllPermissionVariables';
|
import { getAllPermissionVariables } from '@/features/projects/permissions/settings/utils/getAllPermissionVariables';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
import { useGetRolesPermissionsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { CommandLoading } from 'cmdk';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||||
import useRuleGroupEditor from './useRuleGroupEditor';
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
|
|
||||||
@@ -41,23 +65,7 @@ function ColumnSelectorInput({
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
disableRelationships
|
disableRelationships
|
||||||
slotProps={{
|
onChange={({ value }) => {
|
||||||
input: {
|
|
||||||
className: 'lg:!rounded-none !z-10',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? theme.palette.grey[300]
|
|
||||||
: theme.palette.common.white,
|
|
||||||
[`& .${inputClasses.input}`]: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
onChange={(_event, { value }) => {
|
|
||||||
if (selectedTablePath === `${schema}.${table}`) {
|
if (selectedTablePath === `${schema}.${table}`) {
|
||||||
setValue(name, [value], { shouldDirty: true });
|
setValue(name, [value], { shouldDirty: true });
|
||||||
return;
|
return;
|
||||||
@@ -75,113 +83,92 @@ export interface RuleValueInputProps {
|
|||||||
* Name of the parent group editor.
|
* Name of the parent group editor.
|
||||||
*/
|
*/
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Class name to apply to the input wrapper.
|
||||||
|
*/
|
||||||
|
className?: string;
|
||||||
/**
|
/**
|
||||||
* Path of the table selected through the column input.
|
* Path of the table selected through the column input.
|
||||||
*/
|
*/
|
||||||
selectedTablePath?: string;
|
selectedTablePath?: string;
|
||||||
/**
|
|
||||||
* Whether the input should be marked as invalid.
|
|
||||||
*/
|
|
||||||
error?: InputProps['error'];
|
|
||||||
/**
|
|
||||||
* Helper text to display below the input.
|
|
||||||
*/
|
|
||||||
helperText?: InputProps['helperText'];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RuleValueInput({
|
export default function RuleValueInput({
|
||||||
name,
|
name,
|
||||||
selectedTablePath,
|
selectedTablePath,
|
||||||
error,
|
className,
|
||||||
helperText,
|
|
||||||
}: RuleValueInputProps) {
|
}: RuleValueInputProps) {
|
||||||
const { schema, table, disabled } = useRuleGroupEditor();
|
const { schema, table, disabled } = useRuleGroupEditor();
|
||||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
const { project } = useProject();
|
||||||
const { setValue } = useFormContext();
|
const { setValue, control } = useFormContext();
|
||||||
const inputName = `${name}.value`;
|
const inputName = `${name}.value`;
|
||||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
const { field } = useController({
|
||||||
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
|
name: inputName,
|
||||||
const sharedInputSx: InputProps['sx'] = !disabled
|
control,
|
||||||
? {
|
});
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? theme.palette.grey[300]
|
|
||||||
: theme.palette.common.white,
|
|
||||||
[`& .${inputClasses.input}`]: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const {
|
const [open, setOpen] = useState(false);
|
||||||
data,
|
const comboboxValue = useWatch({ name: inputName });
|
||||||
loading,
|
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||||
error: customClaimsError,
|
|
||||||
} = useGetRolesPermissionsQuery({
|
const isPlatform = useIsPlatform();
|
||||||
variables: { appId: currentProject?.id },
|
const localMimirClient = useLocalMimirClient();
|
||||||
skip: !isHasuraInput || !currentProject?.id,
|
|
||||||
|
const { data, loading } = useGetRolesPermissionsQuery({
|
||||||
|
variables: { appId: project?.id },
|
||||||
|
skip: !project?.id,
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (operator === '_is_null') {
|
if (operator === '_is_null') {
|
||||||
|
const defaultValue = !Array.isArray(comboboxValue) ? comboboxValue : null;
|
||||||
return (
|
return (
|
||||||
<ControlledSelect
|
<Select
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
fullWidth
|
onValueChange={(newValue: string) => {
|
||||||
slotProps={{
|
setValue(inputName, newValue, { shouldDirty: true });
|
||||||
root: {
|
|
||||||
className: 'lg:!rounded-none h-10',
|
|
||||||
sx: !disabled
|
|
||||||
? {
|
|
||||||
backgroundColor: (theme) =>
|
|
||||||
theme.palette.mode === 'dark'
|
|
||||||
? `${theme.palette.grey[300]} !important`
|
|
||||||
: `${theme.palette.common.white} !important`,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
popper: { disablePortal: false, className: 'z-[10000]' },
|
|
||||||
}}
|
}}
|
||||||
error={error}
|
defaultValue={defaultValue}
|
||||||
helperText={helperText}
|
|
||||||
>
|
>
|
||||||
<Option value="true">
|
<SelectTrigger className="border hover:bg-accent hover:text-accent-foreground focus:ring-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
|
||||||
<ReadOnlyToggle
|
<SelectValue placeholder="Is null?" />
|
||||||
checked
|
</SelectTrigger>
|
||||||
slotProps={{ label: { className: '!text-sm' } }}
|
<SelectContent>
|
||||||
/>
|
<SelectItem value="true">
|
||||||
</Option>
|
<span className="font-medium">true</span>
|
||||||
|
</SelectItem>
|
||||||
<Option value="false">
|
<SelectItem value="false">
|
||||||
<ReadOnlyToggle
|
<span className="font-medium">false</span>
|
||||||
checked={false}
|
</SelectItem>
|
||||||
slotProps={{ label: { className: '!text-sm' } }}
|
</SelectContent>
|
||||||
/>
|
</Select>
|
||||||
</Option>
|
|
||||||
</ControlledSelect>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
||||||
|
data?.config?.auth?.session?.accessToken?.customClaims,
|
||||||
|
).map(({ key }) => ({
|
||||||
|
value: `X-Hasura-${key}`,
|
||||||
|
label: `X-Hasura-${key}`,
|
||||||
|
group: 'Frequently used',
|
||||||
|
}));
|
||||||
|
|
||||||
if (operator === '_in' || operator === '_nin') {
|
if (operator === '_in' || operator === '_nin') {
|
||||||
|
const defaultValue = Array.isArray(field.value) ? field.value : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<FancyMultiSelect
|
||||||
disabled={disabled}
|
className={className}
|
||||||
name={inputName}
|
options={availableHasuraPermissionVariables}
|
||||||
multiple
|
creatable
|
||||||
freeSolo
|
defaultValue={defaultValue.map((v) => ({ value: v, label: v }))}
|
||||||
limitTags={3}
|
onChange={(value) => {
|
||||||
slotProps={{
|
setValue(
|
||||||
input: {
|
inputName,
|
||||||
className: 'lg:!rounded-none !z-10',
|
value.map((v) => v.value),
|
||||||
sx: sharedInputSx,
|
{ shouldDirty: true },
|
||||||
},
|
);
|
||||||
paper: { className: 'hidden' },
|
|
||||||
}}
|
}}
|
||||||
options={[]}
|
|
||||||
fullWidth
|
|
||||||
filterSelectedOptions
|
|
||||||
error={error}
|
|
||||||
helperText={helperText}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -194,71 +181,70 @@ export default function RuleValueInput({
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
error={error}
|
|
||||||
helperText={helperText}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableHasuraPermissionVariables = getAllPermissionVariables(
|
const selectedVariable = availableHasuraPermissionVariables.find(
|
||||||
data?.config?.auth?.session?.accessToken?.customClaims,
|
(variable) => variable.value === comboboxValue,
|
||||||
).map(({ key }) => ({
|
);
|
||||||
value: `X-Hasura-${key}`,
|
const comboboxLabel =
|
||||||
label: `X-Hasura-${key}`,
|
selectedVariable?.label || comboboxValue || 'Select variable...';
|
||||||
group: 'Frequently used',
|
|
||||||
}));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
disabled={disabled}
|
<PopoverTrigger asChild>
|
||||||
freeSolo={!isHasuraInput}
|
<Button
|
||||||
autoHighlight={isHasuraInput}
|
variant="outline"
|
||||||
isOptionEqualToValue={(
|
role="combobox"
|
||||||
option,
|
aria-expanded={open}
|
||||||
value: string | number | AutocompleteOption<string>,
|
className="justify-between"
|
||||||
) => {
|
>
|
||||||
if (typeof value !== 'object') {
|
<span className="truncate">{comboboxLabel}</span>
|
||||||
return option.value.toLowerCase() === value?.toString().toLowerCase();
|
<ChevronsUpDown className="h-5 min-h-5 w-5 min-w-5 opacity-50" />
|
||||||
}
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
return option.value.toLowerCase() === value.value.toLowerCase();
|
<PopoverContent
|
||||||
}}
|
side="bottom"
|
||||||
name={inputName}
|
align="start"
|
||||||
groupBy={(option) => option.group}
|
className="max-h-[var(--radix-popover-content-available-height)] w-[var(--radix-popover-trigger-width)] p-0"
|
||||||
slotProps={{
|
>
|
||||||
input: {
|
<Command>
|
||||||
className: 'lg:!rounded-none',
|
<CommandInput placeholder="Choose variable..." />
|
||||||
sx: sharedInputSx,
|
<CommandList>
|
||||||
},
|
<CommandEmpty>No variable found.</CommandEmpty>
|
||||||
formControl: { className: '!bg-transparent' },
|
{loading && <CommandLoading>Loading...</CommandLoading>}
|
||||||
paper: { className: 'empty:border-transparent' },
|
<CommandGroup>
|
||||||
}}
|
{availableHasuraPermissionVariables.map((variable) => (
|
||||||
fullWidth
|
<CommandItem
|
||||||
loading={loading}
|
key={variable.value}
|
||||||
loadingText={<ActivityIndicator label="Loading..." />}
|
value={variable.value}
|
||||||
error={Boolean(customClaimsError) || error}
|
onSelect={(currentValue) => {
|
||||||
helperText={customClaimsError?.message || helperText}
|
setValue(inputName, currentValue, { shouldDirty: true });
|
||||||
options={
|
setOpen(false);
|
||||||
isHasuraInput
|
}}
|
||||||
? availableHasuraPermissionVariables
|
>
|
||||||
: [
|
{variable.label}
|
||||||
{
|
<Check
|
||||||
value: 'X-Hasura-User-Id',
|
className={cn(
|
||||||
label: 'X-Hasura-User-Id',
|
'ml-auto',
|
||||||
group: 'Frequently used',
|
comboboxValue === variable.value
|
||||||
},
|
? 'opacity-100'
|
||||||
]
|
: 'opacity-0',
|
||||||
}
|
)}
|
||||||
onChange={(_event, _value, reason, details) => {
|
/>
|
||||||
if (
|
</CommandItem>
|
||||||
reason !== 'selectOption' &&
|
))}
|
||||||
details.option.value !== 'X-Hasura-User-Id'
|
</CommandGroup>
|
||||||
) {
|
<CommandCreateItem
|
||||||
return;
|
onCreate={(currentValue) => {
|
||||||
}
|
setValue(inputName, currentValue, { shouldDirty: true });
|
||||||
|
setOpen(false);
|
||||||
setValue(inputName, details.option.value, { shouldDirty: true });
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -544,9 +544,7 @@ export type HasuraOperator =
|
|||||||
| '_eq'
|
| '_eq'
|
||||||
| '_neq'
|
| '_neq'
|
||||||
| '_in'
|
| '_in'
|
||||||
| '_in_hasura'
|
|
||||||
| '_nin'
|
| '_nin'
|
||||||
| '_nin_hasura'
|
|
||||||
| '_gt'
|
| '_gt'
|
||||||
| '_lt'
|
| '_lt'
|
||||||
| '_gte'
|
| '_gte'
|
||||||
|
|||||||
@@ -202,36 +202,6 @@ test('should convert a complex permission to a rule group', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test(`should convert an _in or _nin value that do not have an array as value to _in_hasura or _nin_hasura`, () => {
|
|
||||||
expect(
|
|
||||||
convertToRuleGroup({ title: { _in: ['X-Hasura-Allowed-Ids'] } }),
|
|
||||||
).toMatchObject({
|
|
||||||
operator: '_and',
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
column: 'title',
|
|
||||||
operator: '_in',
|
|
||||||
value: ['X-Hasura-Allowed-Ids'],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
convertToRuleGroup({ title: { _in: 'X-Hasura-Allowed-Ids' } }),
|
|
||||||
).toMatchObject({
|
|
||||||
operator: '_and',
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
column: 'title',
|
|
||||||
operator: '_in_hasura',
|
|
||||||
value: 'X-Hasura-Allowed-Ids',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
groups: [],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should transform operators and relations if the _not operator is being used', () => {
|
test('should transform operators and relations if the _not operator is being used', () => {
|
||||||
expect(
|
expect(
|
||||||
convertToRuleGroup({ _not: { title: { _eq: 'test' } } }),
|
convertToRuleGroup({ _not: { title: { _eq: 'test' } } }),
|
||||||
|
|||||||
@@ -52,8 +52,6 @@ const negatedValueOperatorPairs: Record<HasuraOperator, HasuraOperator> = {
|
|||||||
_cgte: '_clt',
|
_cgte: '_clt',
|
||||||
_clte: '_cgt',
|
_clte: '_cgt',
|
||||||
_is_null: '_is_null',
|
_is_null: '_is_null',
|
||||||
_in_hasura: '_nin_hasura',
|
|
||||||
_nin_hasura: '_in_hasura',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function convertToRuleGroup(
|
export default function convertToRuleGroup(
|
||||||
@@ -151,16 +149,14 @@ export default function convertToRuleGroup(
|
|||||||
(currentKey === '_in' || currentKey === '_nin') &&
|
(currentKey === '_in' || currentKey === '_nin') &&
|
||||||
typeof value === 'string'
|
typeof value === 'string'
|
||||||
) {
|
) {
|
||||||
const operator = currentKey === '_in' ? '_in_hasura' : '_nin_hasura';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
operator: '_and',
|
operator: '_and',
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
column: previousKey,
|
column: previousKey,
|
||||||
operator: shouldNegate
|
operator: shouldNegate
|
||||||
? negatedValueOperatorPairs[operator]
|
? negatedValueOperatorPairs[currentKey]
|
||||||
: operator,
|
: currentKey,
|
||||||
value,
|
value,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -19,20 +19,23 @@ export const POSTGRESQL_ERROR_CODES = {
|
|||||||
*
|
*
|
||||||
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
|
* @docs https://www.postgresql.org/docs/current/datatype-numeric.html
|
||||||
*/
|
*/
|
||||||
export const POSTGRESQL_NUMERIC_TYPES = [
|
export const POSTGRESQL_INTEGER_TYPES = [
|
||||||
'smallint',
|
'smallint',
|
||||||
'integer',
|
'integer',
|
||||||
'bigint',
|
'bigint',
|
||||||
'decimal',
|
|
||||||
'numeric',
|
|
||||||
'real',
|
|
||||||
'double precision',
|
|
||||||
'smallserial',
|
'smallserial',
|
||||||
'serial',
|
'serial',
|
||||||
'bigserial',
|
'bigserial',
|
||||||
'oid',
|
'oid',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const POSTGRESQL_DECIMAL_TYPES = [
|
||||||
|
'decimal',
|
||||||
|
'numeric',
|
||||||
|
'real',
|
||||||
|
'double precision',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Character data types in PostgreSQL.
|
* Character data types in PostgreSQL.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -118,8 +118,8 @@ export default function DatabaseConnectionInfo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const postgresHost = generateAppServiceUrl(
|
const postgresHost = generateAppServiceUrl(
|
||||||
project.subdomain,
|
project?.subdomain,
|
||||||
project.region,
|
project?.region,
|
||||||
'db',
|
'db',
|
||||||
).replace('https://', '');
|
).replace('https://', '');
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ const validationSchema = Yup.object({
|
|||||||
value: Yup.string().required('Major version is a required field'),
|
value: Yup.string().required('Major version is a required field'),
|
||||||
})
|
})
|
||||||
.label('Postgres major version')
|
.label('Postgres major version')
|
||||||
.required(),
|
.required()
|
||||||
|
.test('not-zero', 'Invalid major version', (value) => value?.value !== '0'),
|
||||||
minorVersion: Yup.object({
|
minorVersion: Yup.object({
|
||||||
label: Yup.string().required(),
|
label: Yup.string().required(),
|
||||||
value: Yup.string().required('Minor version is a required field'),
|
value: Yup.string().required('Minor version is a required field'),
|
||||||
@@ -186,18 +187,29 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
shouldPoll: true,
|
shouldPoll: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showMigrateWarning =
|
|
||||||
Number(selectedMajor) > Number(currentPostgresMajor);
|
|
||||||
|
|
||||||
const { state } = useAppState();
|
const { state } = useAppState();
|
||||||
const applicationUpdating =
|
const applicationUpdating =
|
||||||
state === ApplicationStatus.Updating ||
|
state === ApplicationStatus.Updating ||
|
||||||
state === ApplicationStatus.Migrating;
|
state === ApplicationStatus.Migrating;
|
||||||
|
|
||||||
|
const applicationLive = state === ApplicationStatus.Live;
|
||||||
|
const applicationPaused = state === ApplicationStatus.Paused;
|
||||||
|
const applicationPausing = state === ApplicationStatus.Pausing;
|
||||||
|
|
||||||
|
const showMigrateWarning =
|
||||||
|
!applicationPaused &&
|
||||||
|
!applicationPausing &&
|
||||||
|
Number(selectedMajor) > Number(currentPostgresMajor);
|
||||||
|
|
||||||
const applicationUnhealthy =
|
const applicationUnhealthy =
|
||||||
state !== ApplicationStatus.Live && !applicationUpdating;
|
!applicationLive &&
|
||||||
|
!applicationPaused &&
|
||||||
|
!applicationPausing &&
|
||||||
|
!applicationUpdating;
|
||||||
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
||||||
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
||||||
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
||||||
|
|
||||||
const versionFieldsDisabled =
|
const versionFieldsDisabled =
|
||||||
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
||||||
const saveDisabled = versionFieldsDisabled || !isDirty;
|
const saveDisabled = versionFieldsDisabled || !isDirty;
|
||||||
@@ -208,7 +220,7 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
const newVersion = `${formValues.majorVersion.value}.${formValues.minorVersion.value}`;
|
||||||
|
|
||||||
// Major version change
|
// Major version change
|
||||||
if (isMajorVersionDirty) {
|
if (isMajorVersionDirty && applicationLive) {
|
||||||
openDialog({
|
openDialog({
|
||||||
title: 'Update Postgres MAJOR version',
|
title: 'Update Postgres MAJOR version',
|
||||||
component: (
|
component: (
|
||||||
@@ -228,7 +240,7 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Minor version change
|
// Only minor version change or project is paused/pausing
|
||||||
const updateConfigPromise = updateConfig({
|
const updateConfigPromise = updateConfig({
|
||||||
variables: {
|
variables: {
|
||||||
appId: project.id,
|
appId: project.id,
|
||||||
@@ -338,7 +350,6 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
return option.value;
|
return option.value;
|
||||||
}}
|
}}
|
||||||
showCustomOption="auto"
|
showCustomOption="auto"
|
||||||
isOptionEqualToValue={() => false}
|
|
||||||
filterOptions={(options, { inputValue }) => {
|
filterOptions={(options, { inputValue }) => {
|
||||||
const inputValueLower = inputValue.toLowerCase();
|
const inputValueLower = inputValue.toLowerCase();
|
||||||
const matched = [];
|
const matched = [];
|
||||||
@@ -383,12 +394,13 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
form.setValue('majorVersion', value);
|
form.setValue('majorVersion', value);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
clearOnBlur
|
||||||
fullWidth
|
fullWidth
|
||||||
className="lg:col-span-1"
|
className="lg:col-span-1"
|
||||||
label="MAJOR"
|
label="MAJOR"
|
||||||
options={availableMajorVersions}
|
options={availableMajorVersions}
|
||||||
error={!!formState.errors?.majorVersion?.value?.message}
|
error={!!formState.errors?.majorVersion?.message}
|
||||||
helperText={formState.errors?.majorVersion?.value?.message}
|
helperText={formState.errors?.majorVersion?.message}
|
||||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||||
/>
|
/>
|
||||||
<ControlledAutocomplete
|
<ControlledAutocomplete
|
||||||
@@ -424,12 +436,13 @@ export default function DatabaseServiceVersionSettings() {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}}
|
}}
|
||||||
|
clearOnBlur
|
||||||
fullWidth
|
fullWidth
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
label="MINOR"
|
label="MINOR"
|
||||||
options={availableMinorVersions}
|
options={availableMinorVersions}
|
||||||
error={!!formState.errors?.minorVersion?.value?.message}
|
error={!!formState.errors?.minorVersion?.message}
|
||||||
helperText={formState.errors?.minorVersion?.value?.message}
|
helperText={formState.errors?.minorVersion?.message}
|
||||||
showCustomOption="auto"
|
showCustomOption="auto"
|
||||||
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
customOptionLabel={(value) => `Use custom value: "${value}"`}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { useUI } from '@/components/common/UIProvider';
|
|||||||
import { Form } from '@/components/form/Form';
|
import { Form } from '@/components/form/Form';
|
||||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
import { Alert } from '@/components/ui/v2/Alert';
|
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
import { Input } from '@/components/ui/v2/Input';
|
||||||
|
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||||
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
||||||
|
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
|
import { DatabaseStorageCapacityWarning } from '@/features/orgs/projects/database/settings/components/DatabaseStorageCapacityWarning';
|
||||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
@@ -15,18 +17,25 @@ import {
|
|||||||
useGetPostgresSettingsQuery,
|
useGetPostgresSettingsQuery,
|
||||||
useUpdateConfigMutation,
|
useUpdateConfigMutation,
|
||||||
} from '@/generated/graphql';
|
} from '@/generated/graphql';
|
||||||
|
import { ApplicationStatus } from '@/types/application';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useMemo } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
const validationSchema = Yup.object({
|
const validationSchema = Yup.object({
|
||||||
capacity: Yup.number().required().min(10),
|
capacity: Yup.number()
|
||||||
|
.integer('Capacity must be an integer')
|
||||||
|
.typeError('You must specify a number')
|
||||||
|
.min(1, 'Capacity must be greater than 0')
|
||||||
|
.required('Capacity is required'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AuthDomainFormValues = Yup.InferType<typeof validationSchema>;
|
export type DatabaseStorageCapacityFormValues = Yup.InferType<
|
||||||
|
typeof validationSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export default function AuthDomain() {
|
export default function DatabaseStorageCapacity() {
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { org } = useCurrentOrg();
|
const { org } = useCurrentOrg();
|
||||||
const { maintenanceActive } = useUI();
|
const { maintenanceActive } = useUI();
|
||||||
@@ -58,8 +67,32 @@ export default function AuthDomain() {
|
|||||||
resolver: yupResolver(validationSchema),
|
resolver: yupResolver(validationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { formState, register, reset } = form;
|
const { state } = useAppState();
|
||||||
|
|
||||||
|
const applicationPause =
|
||||||
|
state === ApplicationStatus.Paused || state === ApplicationStatus.Pausing;
|
||||||
|
|
||||||
|
const { formState, register, reset, watch } = form;
|
||||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||||
|
const newCapacity = watch('capacity');
|
||||||
|
|
||||||
|
const decreasingSize = newCapacity < capacity;
|
||||||
|
|
||||||
|
const submitDisabled = useMemo(() => {
|
||||||
|
if (!isDirty) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (maintenanceActive) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decreasingSize && !applicationPause) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}, [isDirty, maintenanceActive, decreasingSize, applicationPause]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (data && !loading) {
|
if (data && !loading) {
|
||||||
@@ -81,7 +114,7 @@ export default function AuthDomain() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(formValues: AuthDomainFormValues) {
|
async function handleSubmit(formValues: DatabaseStorageCapacityFormValues) {
|
||||||
await execPromiseWithErrorToast(
|
await execPromiseWithErrorToast(
|
||||||
async () => {
|
async () => {
|
||||||
await updateConfig({
|
await updateConfig({
|
||||||
@@ -120,7 +153,7 @@ export default function AuthDomain() {
|
|||||||
description="Specify the storage capacity for your PostgreSQL database."
|
description="Specify the storage capacity for your PostgreSQL database."
|
||||||
slotProps={{
|
slotProps={{
|
||||||
submitButton: {
|
submitButton: {
|
||||||
disabled: !isDirty || maintenanceActive,
|
disabled: submitDisabled,
|
||||||
loading: formState.isSubmitting,
|
loading: formState.isSubmitting,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -134,25 +167,25 @@ export default function AuthDomain() {
|
|||||||
{...register('capacity')}
|
{...register('capacity')}
|
||||||
id="capacity"
|
id="capacity"
|
||||||
name="capacity"
|
name="capacity"
|
||||||
type="number"
|
type="text"
|
||||||
|
endAdornment={
|
||||||
|
<InputAdornment className="absolute right-2" position="end">
|
||||||
|
GB
|
||||||
|
</InputAdornment>
|
||||||
|
}
|
||||||
fullWidth
|
fullWidth
|
||||||
disabled={project.legacyPlan?.isFree}
|
disabled={project.legacyPlan?.isFree}
|
||||||
className="lg:col-span-2"
|
className="lg:col-span-2"
|
||||||
error={Boolean(formState.errors.capacity?.message)}
|
error={Boolean(formState.errors.capacity?.message)}
|
||||||
helperText={formState.errors.capacity?.message}
|
helperText={formState.errors.capacity?.message}
|
||||||
slotProps={{
|
|
||||||
inputRoot: {
|
|
||||||
min: capacity,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{!project.legacyPlan?.isFree && (
|
{!project.legacyPlan?.isFree && (
|
||||||
<Alert severity="info" className="col-span-6 text-left">
|
<DatabaseStorageCapacityWarning
|
||||||
Note that volumes can only be increased (not decreased). Also, due
|
state={state}
|
||||||
to an AWS limitation, the same volume can only be increased once
|
decreasingSize={decreasingSize}
|
||||||
every 6 hours.
|
isDirty={isDirty}
|
||||||
</Alert>
|
/>
|
||||||
)}
|
)}
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { Alert } from '@/components/ui/v2/Alert';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { ApplicationStatus } from '@/types/application';
|
||||||
|
|
||||||
|
interface DatabaseStorageCapacityWarningProps {
|
||||||
|
state: ApplicationStatus;
|
||||||
|
decreasingSize: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatabaseStorageCapacityWarning({
|
||||||
|
state,
|
||||||
|
decreasingSize,
|
||||||
|
isDirty,
|
||||||
|
}: DatabaseStorageCapacityWarningProps) {
|
||||||
|
const applicationPause =
|
||||||
|
state === ApplicationStatus.Paused || state === ApplicationStatus.Pausing;
|
||||||
|
|
||||||
|
if (!isDirty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state === ApplicationStatus.Live && !decreasingSize) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||||
|
<Text className="flex items-start gap-1 font-semibold">
|
||||||
|
<span>⚠</span> Warning: Increasing disk size
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
Due to AWS limitations, disk size can only be modified once every 6
|
||||||
|
hours. Please ensure you increase capacity sufficiently to cover
|
||||||
|
your needs during this period.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state === ApplicationStatus.Live && decreasingSize) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||||
|
<Text className="flex items-start gap-1 font-semibold">
|
||||||
|
<span>⚠</span> Warning: Decreasing disk size requires project to be
|
||||||
|
paused first.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (applicationPause && decreasingSize) {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||||
|
<div className="flex flex-col gap-2 lg:flex-row lg:justify-between">
|
||||||
|
<Text className="flex items-start gap-1 font-semibold">
|
||||||
|
<span>⚠</span> Warning: Ensure enough space before downsizing.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Text>
|
||||||
|
Before downsizing, ensure enough space for your database, WAL files,
|
||||||
|
and other supporting data to prevent issues when unpausing your
|
||||||
|
project.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as DatabaseStorageCapacityWarning } from './DatabaseStorageCapacityWarning';
|
||||||
@@ -12,10 +12,6 @@ import { useMemo } from 'react';
|
|||||||
|
|
||||||
type Project = GetProjectQuery['apps'][0];
|
type Project = GetProjectQuery['apps'][0];
|
||||||
|
|
||||||
interface UseProjectOptions {
|
|
||||||
poll?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UseProjectReturnType {
|
export interface UseProjectReturnType {
|
||||||
project: Project;
|
project: Project;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
@@ -23,9 +19,7 @@ export interface UseProjectReturnType {
|
|||||||
refetch: (variables?: any) => Promise<any>;
|
refetch: (variables?: any) => Promise<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useProject({
|
export default function useProject(): UseProjectReturnType {
|
||||||
poll = false,
|
|
||||||
}: UseProjectOptions = {}): UseProjectReturnType {
|
|
||||||
const {
|
const {
|
||||||
query: { appSubdomain },
|
query: { appSubdomain },
|
||||||
isReady: isRouterReady,
|
isReady: isRouterReady,
|
||||||
@@ -46,7 +40,7 @@ export default function useProject({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { data, isLoading, refetch, error } = useQuery(
|
const { data, isLoading, refetch, error } = useQuery(
|
||||||
['currentProject', appSubdomain as string],
|
['project', appSubdomain as string],
|
||||||
async () => {
|
async () => {
|
||||||
const response = await client.graphql.request<{
|
const response = await client.graphql.request<{
|
||||||
apps: ProjectFragment[];
|
apps: ProjectFragment[];
|
||||||
@@ -57,11 +51,6 @@ export default function useProject({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: shouldFetchProject,
|
enabled: shouldFetchProject,
|
||||||
keepPreviousData: true,
|
|
||||||
refetchInterval: poll ? 10000 : false,
|
|
||||||
refetchOnWindowFocus: true,
|
|
||||||
staleTime: 1000 * 60 * 5, // 1 minutes
|
|
||||||
cacheTime: 1000 * 60 * 6, //
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useProjectWithState } from './useProjectWithState';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { localApplication } from '@/features/orgs/utils/local-dashboard';
|
||||||
|
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||||
|
import {
|
||||||
|
GetProjectStateDocument,
|
||||||
|
type GetProjectQuery,
|
||||||
|
type ProjectFragment,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { useAuthenticationStatus, useNhostClient } from '@nhost/nextjs';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
type Project = GetProjectQuery['apps'][0];
|
||||||
|
|
||||||
|
export interface UseProjectWithStateReturnType {
|
||||||
|
project: Project;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: Error;
|
||||||
|
refetch: (variables?: any) => Promise<any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function useProjectWithState(): UseProjectWithStateReturnType {
|
||||||
|
const {
|
||||||
|
query: { appSubdomain },
|
||||||
|
isReady: isRouterReady,
|
||||||
|
} = useRouter();
|
||||||
|
const client = useNhostClient();
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
const { isAuthenticated, isLoading: isAuthLoading } =
|
||||||
|
useAuthenticationStatus();
|
||||||
|
|
||||||
|
const shouldFetchProject = useMemo(
|
||||||
|
() =>
|
||||||
|
isPlatform &&
|
||||||
|
isAuthenticated &&
|
||||||
|
!isAuthLoading &&
|
||||||
|
!!appSubdomain &&
|
||||||
|
isRouterReady,
|
||||||
|
[isPlatform, isAuthenticated, isAuthLoading, appSubdomain, isRouterReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, refetch, error } = useQuery(
|
||||||
|
['projectWithState', appSubdomain as string],
|
||||||
|
async () => {
|
||||||
|
const response = await client.graphql.request<{
|
||||||
|
apps: ProjectFragment[];
|
||||||
|
}>(GetProjectStateDocument, {
|
||||||
|
subdomain: (appSubdomain as string) || '',
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: shouldFetchProject,
|
||||||
|
keepPreviousData: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchInterval: 10000, // poll every 10s
|
||||||
|
staleTime: 1000 * 60 * 5, // 1 minutes
|
||||||
|
cacheTime: 1000 * 60 * 6, //
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isPlatform) {
|
||||||
|
return {
|
||||||
|
project: data?.data?.apps?.[0] || null,
|
||||||
|
loading: isLoading && shouldFetchProject,
|
||||||
|
error: Array.isArray(error || {}) ? error[0] : error,
|
||||||
|
refetch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: localApplication,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
refetch: () => Promise.resolve(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ const smtpValidationSchema = yup
|
|||||||
.required(),
|
.required(),
|
||||||
user: yup.string().label('Username').required(),
|
user: yup.string().label('Username').required(),
|
||||||
password: yup.string().label('Password'),
|
password: yup.string().label('Password'),
|
||||||
sender: yup.string().label('SMTP Sender').email().required(),
|
sender: yup.string().label('SMTP Sender').required(),
|
||||||
})
|
})
|
||||||
.required();
|
.required();
|
||||||
|
|
||||||
|
|||||||
@@ -18,15 +18,19 @@ import { calculateBillableResources } from '@/features/orgs/projects/resources/s
|
|||||||
import type { ResourceSettingsFormValues } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
import type { ResourceSettingsFormValues } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||||
import { resourceSettingsValidationSchema } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
import { resourceSettingsValidationSchema } from '@/features/orgs/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import {
|
import type {
|
||||||
RESOURCE_VCPU_MULTIPLIER,
|
ConfigConfigUpdateInput,
|
||||||
RESOURCE_VCPU_PRICE,
|
GetResourcesQuery,
|
||||||
} from '@/utils/constants/common';
|
} from '@/utils/__generated__/graphql';
|
||||||
import type { GetResourcesQuery } from '@/utils/__generated__/graphql';
|
|
||||||
import {
|
import {
|
||||||
useGetResourcesQuery,
|
useGetResourcesQuery,
|
||||||
useUpdateConfigMutation,
|
useUpdateConfigMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import {
|
||||||
|
RESOURCE_VCPU_MULTIPLIER,
|
||||||
|
RESOURCE_VCPU_PRICE,
|
||||||
|
} from '@/utils/constants/common';
|
||||||
|
import { removeTypename } from '@/utils/helpers';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
@@ -36,7 +40,7 @@ function getInitialServiceResources(
|
|||||||
data: GetResourcesQuery,
|
data: GetResourcesQuery,
|
||||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||||
) {
|
) {
|
||||||
const { compute, replicas, autoscaler } =
|
const { compute, replicas, autoscaler, ...rest } =
|
||||||
data?.config?.[service]?.resources || {};
|
data?.config?.[service]?.resources || {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -44,6 +48,7 @@ function getInitialServiceResources(
|
|||||||
vcpu: compute?.cpu || 0,
|
vcpu: compute?.cpu || 0,
|
||||||
memory: compute?.memory || 0,
|
memory: compute?.memory || 0,
|
||||||
autoscale: autoscaler || null,
|
autoscale: autoscaler || null,
|
||||||
|
rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,76 +181,130 @@ export default function ResourcesForm() {
|
|||||||
? (billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE
|
? (billableResources.vcpu / RESOURCE_VCPU_MULTIPLIER) * RESOURCE_VCPU_PRICE
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const getFormattedConfig = (
|
||||||
|
values: ResourceSettingsFormValues,
|
||||||
|
): ConfigConfigUpdateInput => {
|
||||||
|
const sanitizedValues = removeTypename(
|
||||||
|
values,
|
||||||
|
) as ResourceSettingsFormValues;
|
||||||
|
|
||||||
|
const sanitizedInitialDatabaseResources = removeTypename(
|
||||||
|
initialDatabaseResources,
|
||||||
|
);
|
||||||
|
const sanitizedInitialHasuraResources = removeTypename(
|
||||||
|
initialHasuraResources,
|
||||||
|
);
|
||||||
|
const sanitizedInitialAuthResources = removeTypename(initialAuthResources);
|
||||||
|
const sanitizedInitialStorageResources = removeTypename(
|
||||||
|
initialStorageResources,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sanitizedValues.enabled) {
|
||||||
|
return {
|
||||||
|
postgres: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.database.vcpu,
|
||||||
|
memory: sanitizedValues.database.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.database.replicas,
|
||||||
|
autoscaler: sanitizedValues.database.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.database.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialDatabaseResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasura: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.hasura.vcpu,
|
||||||
|
memory: sanitizedValues.hasura.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.hasura.replicas,
|
||||||
|
autoscaler: sanitizedValues.hasura.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.hasura.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialHasuraResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.auth.vcpu,
|
||||||
|
memory: sanitizedValues.auth.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.auth.replicas,
|
||||||
|
autoscaler: sanitizedValues.auth.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.auth.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialAuthResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
resources: {
|
||||||
|
compute: {
|
||||||
|
cpu: sanitizedValues.storage.vcpu,
|
||||||
|
memory: sanitizedValues.storage.memory,
|
||||||
|
},
|
||||||
|
replicas: sanitizedValues.storage.replicas,
|
||||||
|
autoscaler: sanitizedValues.storage.autoscale
|
||||||
|
? {
|
||||||
|
maxReplicas: sanitizedValues.storage.maxReplicas,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
...sanitizedInitialStorageResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
postgres: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialDatabaseResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hasura: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialHasuraResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialAuthResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
storage: {
|
||||||
|
resources: {
|
||||||
|
compute: null,
|
||||||
|
replicas: null,
|
||||||
|
autoscaler: null,
|
||||||
|
...sanitizedInitialStorageResources.rest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||||
const updateConfigPromise = updateConfig({
|
const updateConfigPromise = updateConfig({
|
||||||
variables: {
|
variables: {
|
||||||
appId: project?.id,
|
appId: project?.id,
|
||||||
config: {
|
config: getFormattedConfig(formValues),
|
||||||
postgres: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.database.vcpu,
|
|
||||||
memory: formValues.database.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.database.replicas,
|
|
||||||
autoscaler: formValues.database.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.database.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
hasura: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.hasura.vcpu,
|
|
||||||
memory: formValues.hasura.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.hasura.replicas,
|
|
||||||
autoscaler: formValues.hasura.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.hasura.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.auth.vcpu,
|
|
||||||
memory: formValues.auth.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.auth.replicas,
|
|
||||||
autoscaler: formValues.auth.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.auth.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
storage: {
|
|
||||||
resources: formValues.enabled
|
|
||||||
? {
|
|
||||||
compute: {
|
|
||||||
cpu: formValues.storage.vcpu,
|
|
||||||
memory: formValues.storage.memory,
|
|
||||||
},
|
|
||||||
replicas: formValues.storage.replicas,
|
|
||||||
autoscaler: formValues.storage.autoscale
|
|
||||||
? {
|
|
||||||
maxReplicas: formValues.storage.maxReplicas,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="grid grid-flow-row gap-2">
|
<Box className="grid grid-flow-row gap-2">
|
||||||
<Box className="grid items-center justify-between grid-flow-col gap-2">
|
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||||
<Text>
|
<Text>
|
||||||
Allocated vCPUs:{' '}
|
Allocated vCPUs:{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
@@ -201,7 +201,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box className="grid grid-flow-row gap-2">
|
<Box className="grid grid-flow-row gap-2">
|
||||||
<Box className="grid items-center justify-between grid-flow-col gap-2">
|
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||||
<Text>
|
<Text>
|
||||||
Allocated Memory:{' '}
|
Allocated Memory:{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">
|
||||||
@@ -246,7 +246,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
>
|
>
|
||||||
<ExclamationIcon
|
<ExclamationIcon
|
||||||
color="error"
|
color="error"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -274,7 +274,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
>
|
>
|
||||||
<ExclamationIcon
|
<ExclamationIcon
|
||||||
color="error"
|
color="error"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
aria-hidden="false"
|
aria-hidden="false"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -306,7 +306,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
<Tooltip
|
<Tooltip
|
||||||
title={`Enable autoscaler to automatically provision extra ${title} replicas when needed.`}
|
title={`Enable autoscaler to automatically provision extra ${title} replicas when needed.`}
|
||||||
>
|
>
|
||||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
<InfoOutlinedIcon className="h-4 w-4" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -323,7 +323,7 @@ export default function ServiceResourcesFormFragment({
|
|||||||
className="font-medium"
|
className="font-medium"
|
||||||
>
|
>
|
||||||
Service Replicas
|
Service Replicas
|
||||||
<ArrowSquareOutIcon className="w-4 h-4 ml-1" />
|
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||||
</Link>
|
</Link>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ fragment ServiceResources on ConfigConfig {
|
|||||||
autoscaler {
|
autoscaler {
|
||||||
maxReplicas
|
maxReplicas
|
||||||
}
|
}
|
||||||
|
networking {
|
||||||
|
ingresses {
|
||||||
|
fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasura {
|
hasura {
|
||||||
@@ -21,10 +26,19 @@ fragment ServiceResources on ConfigConfig {
|
|||||||
autoscaler {
|
autoscaler {
|
||||||
maxReplicas
|
maxReplicas
|
||||||
}
|
}
|
||||||
|
networking {
|
||||||
|
ingresses {
|
||||||
|
fqdn
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
postgres {
|
postgres {
|
||||||
resources {
|
resources {
|
||||||
|
storage {
|
||||||
|
capacity
|
||||||
|
}
|
||||||
|
enablePublicAccess
|
||||||
compute {
|
compute {
|
||||||
cpu
|
cpu
|
||||||
memory
|
memory
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { StorageFormSection } from '@/features/orgs/projects/services/components
|
|||||||
import { useHostName } from '@/features/projects/common/hooks/useHostName';
|
import { useHostName } from '@/features/projects/common/hooks/useHostName';
|
||||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||||
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
import { COST_PER_VCPU } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validationSchema,
|
validationSchema,
|
||||||
@@ -29,16 +28,15 @@ import {
|
|||||||
|
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||||
|
import {
|
||||||
|
useInsertRunServiceConfigMutation,
|
||||||
|
useReplaceRunServiceConfigMutation,
|
||||||
|
type ConfigRunServiceConfigInsertInput,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||||
import { copy } from '@/utils/copy';
|
import { copy } from '@/utils/copy';
|
||||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
import { removeTypename } from '@/utils/helpers';
|
import { removeTypename } from '@/utils/helpers';
|
||||||
import {
|
|
||||||
useInsertRunServiceConfigMutation,
|
|
||||||
useInsertRunServiceMutation,
|
|
||||||
useReplaceRunServiceConfigMutation,
|
|
||||||
type ConfigRunServiceConfigInsertInput,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
@@ -58,9 +56,10 @@ export default function ServiceForm({
|
|||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const localMimirClient = useLocalMimirClient();
|
const localMimirClient = useLocalMimirClient();
|
||||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||||
const [insertRunService] = useInsertRunServiceMutation();
|
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation({
|
||||||
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
|
});
|
||||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||||
});
|
});
|
||||||
@@ -96,14 +95,14 @@ export default function ServiceForm({
|
|||||||
if (serviceID) {
|
if (serviceID) {
|
||||||
return serviceID;
|
return serviceID;
|
||||||
}
|
}
|
||||||
return uuidv4();
|
return '<uuid-to-be-generated-on-creation>';
|
||||||
}, [serviceID]);
|
}, [serviceID]);
|
||||||
|
|
||||||
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
const privateRegistryImage = `registry.${project?.region.name}.${project?.region.domain}/${newServiceID}`;
|
||||||
|
|
||||||
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
let initialImageType: 'public' | 'private' | 'nhost' = 'public';
|
||||||
|
|
||||||
if (initialData?.image?.startsWith(privateRegistryImage)) {
|
if (initialData?.image?.startsWith(privateRegistryImage.split('/')[0])) {
|
||||||
initialImageType = 'nhost';
|
initialImageType = 'nhost';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,33 +224,14 @@ export default function ServiceForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Insert service config
|
// Create service
|
||||||
const {
|
|
||||||
data: {
|
|
||||||
insertRunService: { id },
|
|
||||||
},
|
|
||||||
} = await insertRunService({
|
|
||||||
variables: {
|
|
||||||
object: {
|
|
||||||
appID: project.id,
|
|
||||||
id: newServiceID,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await insertRunServiceConfig({
|
await insertRunServiceConfig({
|
||||||
variables: {
|
variables: {
|
||||||
appID: project.id,
|
appID: project.id,
|
||||||
serviceID: id,
|
|
||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
image: {
|
image: {
|
||||||
// If the image field left empty then we auto-populate following this format
|
image: values.image,
|
||||||
// registry.<region>.<nhost_domain>/<service_id>
|
|
||||||
image:
|
|
||||||
values.image.length > 0
|
|
||||||
? values.image
|
|
||||||
: `registry.${project.region.name}.${project.region.domain}/${newServiceID}`,
|
|
||||||
pullCredentials:
|
pullCredentials:
|
||||||
values.pullCredentials?.length > 0
|
values.pullCredentials?.length > 0
|
||||||
? values.pullCredentials
|
? values.pullCredentials
|
||||||
@@ -335,7 +315,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Name of the service, must be unique per project.">
|
<Tooltip title="Name of the service, must be unique per project.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -359,7 +339,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -414,7 +394,7 @@ export default function ServiceForm({
|
|||||||
{createServiceFormError && (
|
{createServiceFormError && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="error"
|
severity="error"
|
||||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
<strong>Error:</strong> {createServiceFormError.message}
|
<strong>Error:</strong> {createServiceFormError.message}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ import {
|
|||||||
|
|
||||||
export const validationSchema = Yup.object({
|
export const validationSchema = Yup.object({
|
||||||
name: Yup.string().required('The name is required.'),
|
name: Yup.string().required('The name is required.'),
|
||||||
image: Yup.string().label('Image to run').required('The image is required.'),
|
image: Yup.string()
|
||||||
|
.trim()
|
||||||
|
.label('Image to run')
|
||||||
|
.required('The image is required.')
|
||||||
|
.min(1, 'Image must be at least 1 character long'),
|
||||||
pullCredentials: Yup.string().label('Pull credentials').nullable(),
|
pullCredentials: Yup.string().label('Pull credentials').nullable(),
|
||||||
command: Yup.string(),
|
command: Yup.string(),
|
||||||
environment: Yup.array().of(
|
environment: Yup.array().of(
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function ReplicasFormSection() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="p-4 space-y-4 rounded border-1">
|
<Box className="space-y-4 rounded border-1 p-4">
|
||||||
<Box className="flex flex-row items-center space-x-2">
|
<Box className="flex flex-row items-center space-x-2">
|
||||||
<Text variant="h4" className="font-semibold">
|
<Text variant="h4" className="font-semibold">
|
||||||
Replicas ({replicas})
|
Replicas ({replicas})
|
||||||
@@ -65,7 +65,7 @@ export default function ReplicasFormSection() {
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@@ -121,7 +121,7 @@ export default function ReplicasFormSection() {
|
|||||||
/>
|
/>
|
||||||
<Text>Autoscaler</Text>
|
<Text>Autoscaler</Text>
|
||||||
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
||||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
<InfoOutlinedIcon className="h-4 w-4" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||||
|
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||||
|
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
export type DataGridDecimalCellProps<TData extends object> =
|
||||||
|
CommonDataGridCellProps<TData, number | string>;
|
||||||
|
|
||||||
|
export default function DataGridDecimalCell<TData extends object>({
|
||||||
|
onSave,
|
||||||
|
optimisticValue,
|
||||||
|
temporaryValue,
|
||||||
|
onTemporaryValueChange,
|
||||||
|
}: DataGridDecimalCellProps<TData>) {
|
||||||
|
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||||
|
useDataGridCell<HTMLInputElement>();
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (onSave) {
|
||||||
|
if (typeof temporaryValue === 'string') {
|
||||||
|
await onSave(parseFloat(temporaryValue));
|
||||||
|
} else if (typeof temporaryValue === 'number') {
|
||||||
|
await onSave(temporaryValue);
|
||||||
|
} else {
|
||||||
|
await onSave(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (
|
||||||
|
event.key === 'ArrowLeft' ||
|
||||||
|
event.key === 'ArrowRight' ||
|
||||||
|
event.key === 'ArrowUp' ||
|
||||||
|
event.key === 'ArrowDown' ||
|
||||||
|
event.key === 'Backspace'
|
||||||
|
) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Tab') {
|
||||||
|
await handleSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
await handleSave();
|
||||||
|
await focusCell();
|
||||||
|
cancelEditCell();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||||
|
if (onTemporaryValueChange) {
|
||||||
|
onTemporaryValueChange(event.target.value ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
ref={inputRef}
|
||||||
|
value={
|
||||||
|
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||||
|
? temporaryValue
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onChange={handleChange}
|
||||||
|
fullWidth
|
||||||
|
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||||
|
sx={{
|
||||||
|
[`&.${inputClasses.focused}`]: {
|
||||||
|
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||||
|
borderColor: 'transparent !important',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: (theme) =>
|
||||||
|
theme.palette.mode === 'dark'
|
||||||
|
? `${theme.palette.secondary[100]} !important`
|
||||||
|
: `${theme.palette.common.white} !important`,
|
||||||
|
},
|
||||||
|
[`& .${inputClasses.input}`]: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
inputWrapper: { className: 'h-full' },
|
||||||
|
input: { className: 'h-full' },
|
||||||
|
inputRoot: {
|
||||||
|
className:
|
||||||
|
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optimisticValue === null || typeof optimisticValue === 'undefined') {
|
||||||
|
return (
|
||||||
|
<Text className="truncate !text-xs" color="disabled">
|
||||||
|
null
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Text className="truncate !text-xs">{optimisticValue}</Text>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './DataGridDecimalCell';
|
||||||
|
export { default as DataGridDecimalCell } from './DataGridDecimalCell';
|
||||||
@@ -4,15 +4,15 @@ import { Input, inputClasses } from '@/components/ui/v2/Input';
|
|||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
export type DataGridNumericCellProps<TData extends object> =
|
export type DataGridIntegerCellProps<TData extends object> =
|
||||||
CommonDataGridCellProps<TData, number>;
|
CommonDataGridCellProps<TData, number>;
|
||||||
|
|
||||||
export default function DataGridNumericCell<TData extends object>({
|
export default function DataGridIntegerCell<TData extends object>({
|
||||||
onSave,
|
onSave,
|
||||||
optimisticValue,
|
optimisticValue,
|
||||||
temporaryValue,
|
temporaryValue,
|
||||||
onTemporaryValueChange,
|
onTemporaryValueChange,
|
||||||
}: DataGridNumericCellProps<TData>) {
|
}: DataGridIntegerCellProps<TData>) {
|
||||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||||
useDataGridCell<HTMLInputElement>();
|
useDataGridCell<HTMLInputElement>();
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './DataGridIntegerCell';
|
||||||
|
export { default as DataGridIntegerCell } from './DataGridIntegerCell';
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridNumericCell';
|
|
||||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
|
||||||
@@ -28,16 +28,15 @@ import {
|
|||||||
type ServiceFormValues,
|
type ServiceFormValues,
|
||||||
} from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
} from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||||
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/hooks/useLocalMimirClient';
|
||||||
|
import {
|
||||||
|
useInsertRunServiceConfigMutation,
|
||||||
|
useReplaceRunServiceConfigMutation,
|
||||||
|
type ConfigRunServiceConfigInsertInput,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/constants/common';
|
||||||
import { copy } from '@/utils/copy';
|
import { copy } from '@/utils/copy';
|
||||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
import { removeTypename } from '@/utils/helpers';
|
import { removeTypename } from '@/utils/helpers';
|
||||||
import {
|
|
||||||
useInsertRunServiceConfigMutation,
|
|
||||||
useInsertRunServiceMutation,
|
|
||||||
useReplaceRunServiceConfigMutation,
|
|
||||||
type ConfigRunServiceConfigInsertInput,
|
|
||||||
} from '@/utils/__generated__/graphql';
|
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
@@ -57,7 +56,6 @@ export default function ServiceForm({
|
|||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const localMimirClient = useLocalMimirClient();
|
const localMimirClient = useLocalMimirClient();
|
||||||
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
const { onDirtyStateChange, openDialog, closeDialog } = useDialog();
|
||||||
const [insertRunService] = useInsertRunServiceMutation();
|
|
||||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||||
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
const [insertRunServiceConfig] = useInsertRunServiceConfigMutation();
|
||||||
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
const [replaceRunServiceConfig] = useReplaceRunServiceConfigMutation({
|
||||||
@@ -187,20 +185,11 @@ export default function ServiceForm({
|
|||||||
// Insert service config
|
// Insert service config
|
||||||
const {
|
const {
|
||||||
data: {
|
data: {
|
||||||
insertRunService: { id: newServiceID, subdomain },
|
insertRunServiceConfig: { serviceID: newServiceID },
|
||||||
},
|
},
|
||||||
} = await insertRunService({
|
} = await insertRunServiceConfig({
|
||||||
variables: {
|
|
||||||
object: {
|
|
||||||
appID: currentProject.id,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await insertRunServiceConfig({
|
|
||||||
variables: {
|
variables: {
|
||||||
appID: currentProject.id,
|
appID: currentProject.id,
|
||||||
serviceID: newServiceID,
|
|
||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
image: {
|
image: {
|
||||||
@@ -209,14 +198,14 @@ export default function ServiceForm({
|
|||||||
image:
|
image:
|
||||||
values.image.length > 0
|
values.image.length > 0
|
||||||
? values.image
|
? values.image
|
||||||
: `registry.${currentProject.region.name}.${currentProject.region.domain}/${newServiceID}`,
|
: `registry.${currentProject.region.name}.${currentProject.region.domain}/<uuid-to-be-generated-on-creation>`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setDetailsServiceId(newServiceID);
|
setDetailsServiceId(newServiceID);
|
||||||
setDetailsServiceSubdomain(subdomain);
|
setDetailsServiceSubdomain('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -322,7 +311,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Name of the service, must be unique per project.">
|
<Tooltip title="Name of the service, must be unique per project.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -362,7 +351,7 @@ export default function ServiceForm({
|
|||||||
>
|
>
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -393,7 +382,7 @@ export default function ServiceForm({
|
|||||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||||
<InfoIcon
|
<InfoIcon
|
||||||
aria-label="Info"
|
aria-label="Info"
|
||||||
className="w-4 h-4"
|
className="h-4 w-4"
|
||||||
color="primary"
|
color="primary"
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -441,7 +430,7 @@ export default function ServiceForm({
|
|||||||
{createServiceFormError && (
|
{createServiceFormError && (
|
||||||
<Alert
|
<Alert
|
||||||
severity="error"
|
severity="error"
|
||||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
>
|
>
|
||||||
<span className="text-left">
|
<span className="text-left">
|
||||||
<strong>Error:</strong> {createServiceFormError.message}
|
<strong>Error:</strong> {createServiceFormError.message}
|
||||||
|
|||||||
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!
|
$appID: uuid!
|
||||||
$serviceID: uuid!
|
|
||||||
$config: ConfigRunServiceConfigInsertInput!
|
$config: ConfigRunServiceConfigInsertInput!
|
||||||
) {
|
) {
|
||||||
insertRunServiceConfig(
|
insertRunServiceConfig(appID: $appID, config: $config) {
|
||||||
appID: $appID
|
serviceID
|
||||||
serviceID: $serviceID
|
config {
|
||||||
config: $config
|
name
|
||||||
) {
|
}
|
||||||
name
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -388,9 +388,5 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <ProjectLayout>{page}</ProjectLayout>;
|
||||||
<ProjectLayout contentContainerProps={{ className: 'h-full' }}>
|
|
||||||
{page}
|
|
||||||
</ProjectLayout>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ export default function IndexPage() {
|
|||||||
|
|
||||||
IndexPage.getLayout = function getLayout(page: ReactElement) {
|
IndexPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout title="Dashboard">
|
||||||
title="Dashboard"
|
|
||||||
contentContainerProps={{ className: 'flex w-full flex-col' }}
|
|
||||||
>
|
|
||||||
<Container className="py-0">
|
<Container className="py-0">
|
||||||
<MaintenanceAlert />
|
<MaintenanceAlert />
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export default function AutoEmbeddingsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(isPlatform && !org?.plan?.isFree && !project.config?.ai) ||
|
(isPlatform && !org?.plan?.isFree && !project?.config?.ai) ||
|
||||||
!isGraphiteEnabled
|
!isGraphiteEnabled
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { Form } from '@/components/form/Form';
|
|||||||
import { Container } from '@/components/layout/Container';
|
import { Container } from '@/components/layout/Container';
|
||||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
|
import { Alert } from '@/components/ui/v2/Alert';
|
||||||
import { Input } from '@/components/ui/v2/Input';
|
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 { TransferProject } from '@/features/orgs/components/TransferProject';
|
||||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||||
import { SettingsLayout } from '@/features/orgs/layout/SettingsLayout';
|
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 { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
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 { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
@@ -25,7 +29,7 @@ import { ApplicationStatus } from '@/types/application';
|
|||||||
import { slugifyString } from '@/utils/helpers';
|
import { slugifyString } from '@/utils/helpers';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import type { ReactElement } from 'react';
|
import { useMemo, type ReactElement } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import * as Yup from 'yup';
|
import * as Yup from 'yup';
|
||||||
|
|
||||||
@@ -51,6 +55,20 @@ export default function SettingsGeneralPage() {
|
|||||||
const { project, loading, refetch: refetchProject } = useProject();
|
const { project, loading, refetch: refetchProject } = useProject();
|
||||||
const { state } = useAppState();
|
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 [updateApp] = useUpdateApplicationMutation();
|
||||||
const [deleteApplication] = useBillingDeleteAppMutation();
|
const [deleteApplication] = useBillingDeleteAppMutation();
|
||||||
const [pauseApplication, { loading: pauseApplicationLoading }] =
|
const [pauseApplication, { loading: pauseApplicationLoading }] =
|
||||||
@@ -242,9 +260,49 @@ export default function SettingsGeneralPage() {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
openAlertDialog({
|
openAlertDialog({
|
||||||
title: 'Pause Project?',
|
title: 'Pause Project?',
|
||||||
payload:
|
payload: (
|
||||||
'Are you sure you want to pause this project? It will not be accessible until you unpause it.',
|
<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: {
|
props: {
|
||||||
|
maxWidth: 'sm',
|
||||||
onPrimaryAction: handlePauseApplication,
|
onPrimaryAction: handlePauseApplication,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -224,16 +224,16 @@ export default function UsersPage() {
|
|||||||
if (loadingRemoteAppUsersQuery) {
|
if (loadingRemoteAppUsersQuery) {
|
||||||
return (
|
return (
|
||||||
<Container
|
<Container
|
||||||
className="flex flex-col h-full max-w-9xl"
|
className="flex h-full max-w-9xl flex-col"
|
||||||
rootClassName="h-full"
|
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
|
<Input
|
||||||
className="rounded-sm"
|
className="rounded-sm"
|
||||||
placeholder="Search users"
|
placeholder="Search users"
|
||||||
startAdornment={
|
startAdornment={
|
||||||
<SearchIcon
|
<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' }}
|
sx={{ color: 'text.disabled' }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -241,14 +241,14 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={openCreateUserDialog}
|
onClick={openCreateUserDialog}
|
||||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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..." />
|
<ActivityIndicator label="Loading users..." />
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -256,14 +256,14 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex flex-row place-content-between">
|
||||||
<Input
|
<Input
|
||||||
className="rounded-sm"
|
className="rounded-sm"
|
||||||
placeholder="Search users"
|
placeholder="Search users"
|
||||||
startAdornment={
|
startAdornment={
|
||||||
<SearchIcon
|
<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' }}
|
sx={{ color: 'text.disabled' }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -271,21 +271,21 @@ export default function UsersPage() {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
onClick={openCreateUserDialog}
|
onClick={openCreateUserDialog}
|
||||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{usersCount === 0 ? (
|
{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
|
<UserIcon
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
className="w-10 h-10"
|
className="h-10 w-10"
|
||||||
sx={{ color: 'text.disabled' }}
|
sx={{ color: 'text.disabled' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col space-y-1">
|
<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
|
There are no users yet
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="subtitle1" className="text-center">
|
<Text variant="subtitle1" className="text-center">
|
||||||
@@ -298,34 +298,34 @@ export default function UsersPage() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={openCreateUserDialog}
|
onClick={openCreateUserDialog}
|
||||||
startIcon={<PlusIcon className="w-4 h-4" />}
|
startIcon={<PlusIcon className="h-4 w-4" />}
|
||||||
>
|
>
|
||||||
Create User
|
Create User
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-flow-row gap-2 lg:w-9xl">
|
<div className="lg:w-9xl grid grid-flow-row gap-2">
|
||||||
<div className="grid w-full h-full grid-flow-row pb-4 overflow-hidden">
|
<div className="grid h-full w-full grid-flow-row overflow-hidden pb-4">
|
||||||
<Box className="grid w-full p-2 border-b md:grid-cols-6">
|
<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="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">Signed up at</Text>
|
||||||
<Text className="hidden font-medium md:block">Last Seen</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
|
OAuth Providers
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ===
|
{dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ===
|
||||||
0 &&
|
0 &&
|
||||||
usersCount !== 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
|
<UserIcon
|
||||||
strokeWidth={1}
|
strokeWidth={1}
|
||||||
className="w-10 h-10"
|
className="h-10 w-10"
|
||||||
sx={{ color: 'text.disabled' }}
|
sx={{ color: 'text.disabled' }}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col space-y-1">
|
<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}"
|
No results for "{searchString}"
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="subtitle1" className="text-center">
|
<Text variant="subtitle1" className="text-center">
|
||||||
@@ -388,9 +388,5 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
UsersPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return <ProjectLayout>{page}</ProjectLayout>;
|
||||||
<ProjectLayout contentContainerProps={{ className: 'h-full' }}>
|
|
||||||
{page}
|
|
||||||
</ProjectLayout>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const validationSchema = Yup.object({
|
|||||||
email: Yup.string().label('Email').email().required(),
|
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)({
|
const StyledInput = styled(Input)({
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
@@ -28,10 +28,10 @@ const StyledInput = styled(Input)({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ResetPasswordPage() {
|
export default function NewPasswordPage() {
|
||||||
const { resetPassword, error, isSent } = useResetPassword();
|
const { resetPassword, error, isSent } = useResetPassword();
|
||||||
|
|
||||||
const form = useForm<ResetPasswordFormValues>({
|
const form = useForm<NewPasswordFormValues>({
|
||||||
reValidateMode: 'onSubmit',
|
reValidateMode: 'onSubmit',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
email: '',
|
email: '',
|
||||||
@@ -52,9 +52,11 @@ export default function ResetPasswordPage() {
|
|||||||
);
|
);
|
||||||
}, [error]);
|
}, [error]);
|
||||||
|
|
||||||
async function handleSubmit({ email }: ResetPasswordFormValues) {
|
async function handleSubmit({ email }: NewPasswordFormValues) {
|
||||||
try {
|
try {
|
||||||
await resetPassword(email);
|
await resetPassword(email, {
|
||||||
|
redirectTo: '/password/reset',
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(
|
toast.error(
|
||||||
'An error occurred while signing up. Please try again.',
|
'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 (
|
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
|
Sign In
|
||||||
</Text>
|
</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}>
|
<FormProvider {...form}>
|
||||||
<Form
|
<Form
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
@@ -123,9 +123,9 @@ export default function EmailSignUpPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<NavLink
|
<NavLink
|
||||||
href="/reset-password"
|
href="/password/new"
|
||||||
color="white"
|
color="white"
|
||||||
className="font-semibold justify-self-start"
|
className="justify-self-start font-semibold"
|
||||||
>
|
>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -150,7 +150,7 @@ export default function EmailSignUpPage() {
|
|||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Box>
|
</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?{' '}
|
Don't have an account?{' '}
|
||||||
<NavLink href="/signup" color="white">
|
<NavLink href="/signup" color="white">
|
||||||
Sign Up
|
Sign Up
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import { EnvelopeIcon } from '@/components/ui/v2/icons/EnvelopeIcon';
|
|||||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||||
import { Option } from '@/components/ui/v2/Option';
|
import { Option } from '@/components/ui/v2/Option';
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
|
||||||
import {
|
import {
|
||||||
useGetAllWorkspacesAndProjectsQuery,
|
useGetAllWorkspacesAndProjectsQuery,
|
||||||
useGetOrganizationsQuery,
|
useGetOrganizationsQuery,
|
||||||
type GetAllWorkspacesAndProjectsQuery,
|
type GetAllWorkspacesAndProjectsQuery,
|
||||||
type GetOrganizationsQuery,
|
type GetOrganizationsQuery,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
import { yupResolver } from '@hookform/resolvers/yup';
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { useUserData } from '@nhost/nextjs';
|
import { useUserData } from '@nhost/nextjs';
|
||||||
@@ -175,14 +175,14 @@ function TicketPage() {
|
|||||||
className="flex flex-col items-center justify-center py-10"
|
className="flex flex-col items-center justify-center py-10"
|
||||||
sx={{ backgroundColor: 'background.default' }}
|
sx={{ backgroundColor: 'background.default' }}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col w-full max-w-3xl">
|
<div className="flex w-full max-w-3xl flex-col">
|
||||||
<div className="flex flex-col items-center mb-4">
|
<div className="mb-4 flex flex-col items-center">
|
||||||
<Text variant="h4" className="font-bold">
|
<Text variant="h4" className="font-bold">
|
||||||
Nhost Support
|
Nhost Support
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="h4">How can we help you?</Text>
|
<Text variant="h4">How can we help you?</Text>
|
||||||
</div>
|
</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="grid grid-flow-row gap-4">
|
||||||
<Box className="flex flex-col gap-4">
|
<Box className="flex flex-col gap-4">
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
@@ -205,7 +205,7 @@ function TicketPage() {
|
|||||||
helperText={errors.organization?.message}
|
helperText={errors.organization?.message}
|
||||||
disabled={!!selectedWorkspace}
|
disabled={!!selectedWorkspace}
|
||||||
renderValue={(option) => (
|
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}
|
{option?.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -238,7 +238,7 @@ function TicketPage() {
|
|||||||
helperText={errors.workspace?.message}
|
helperText={errors.workspace?.message}
|
||||||
disabled={!!selectedOrganization}
|
disabled={!!selectedOrganization}
|
||||||
renderValue={(option) => (
|
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}
|
{option?.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -267,7 +267,7 @@ function TicketPage() {
|
|||||||
error={!!errors.project}
|
error={!!errors.project}
|
||||||
helperText={errors.project?.message}
|
helperText={errors.project?.message}
|
||||||
renderValue={(option) => (
|
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}
|
{option?.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -318,7 +318,7 @@ function TicketPage() {
|
|||||||
root: { className: 'grid grid-flow-col gap-1 mb-4' },
|
root: { className: 'grid grid-flow-col gap-1 mb-4' },
|
||||||
}}
|
}}
|
||||||
renderValue={(option) => (
|
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}
|
{option?.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -401,8 +401,8 @@ function TicketPage() {
|
|||||||
helperText={errors.ccs?.message}
|
helperText={errors.ccs?.message}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Box className="flex flex-col gap-4 ml-auto w-80">
|
<Box className="ml-auto flex w-80 flex-col gap-4">
|
||||||
<Text color="secondary" className="text-sm text-right">
|
<Text color="secondary" className="text-right text-sm">
|
||||||
We will contact you at <strong>{user?.email}</strong>
|
We will contact you at <strong>{user?.email}</strong>
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -429,12 +429,7 @@ function TicketPage() {
|
|||||||
|
|
||||||
TicketPage.getLayout = function getLayout(page: ReactElement) {
|
TicketPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return (
|
return (
|
||||||
<AuthenticatedLayout
|
<AuthenticatedLayout title="Help & Support | Nhost">
|
||||||
title="Help & Support | Nhost"
|
|
||||||
contentContainerProps={{
|
|
||||||
className: 'flex w-full flex-col h-screen overflow-auto',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{page}
|
{page}
|
||||||
</AuthenticatedLayout>
|
</AuthenticatedLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
677
dashboard/src/utils/__generated__/graphql.ts
generated
677
dashboard/src/utils/__generated__/graphql.ts
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,12 @@
|
|||||||
# @nhost/docs
|
# @nhost/docs
|
||||||
|
|
||||||
|
## 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
|
## 2.24.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ icon: react
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Create Project">
|
<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>
|
||||||
|
|
||||||
<Step title="Setup Database">
|
<Step title="Setup Database">
|
||||||
@@ -47,7 +47,7 @@ icon: react
|
|||||||
Create a Next.js application.
|
Create a Next.js application.
|
||||||
|
|
||||||
```bash Terminal
|
```bash Terminal
|
||||||
npx create-next-app@latest --no-eslint \
|
npx create-next-app@next-14 --no-eslint \
|
||||||
--src-dir \
|
--src-dir \
|
||||||
--no-tailwind \
|
--no-tailwind \
|
||||||
--import-alias "@/*" \
|
--import-alias "@/*" \
|
||||||
@@ -59,7 +59,7 @@ icon: react
|
|||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step title="Install the Nhost package for Next.js">
|
<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
|
```bash Terminal
|
||||||
cd nhost-nextjs-quickstart && npm install @nhost/nextjs
|
cd nhost-nextjs-quickstart && npm install @nhost/nextjs
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ icon: mobile-notch
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Create Nhost Project">
|
<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>
|
||||||
|
|
||||||
<Step title="Setup Database">
|
<Step title="Setup Database">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ icon: react
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Create Nhost Project">
|
<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>
|
||||||
|
|
||||||
<Step title="Setup Database">
|
<Step title="Setup Database">
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ icon: vuejs
|
|||||||
|
|
||||||
<Steps>
|
<Steps>
|
||||||
<Step title="Create Project">
|
<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>
|
||||||
|
|
||||||
<Step title="Setup Database">
|
<Step title="Setup Database">
|
||||||
@@ -53,7 +53,7 @@ icon: vuejs
|
|||||||
</Step>
|
</Step>
|
||||||
|
|
||||||
<Step title="Install the Nhost package for Vue">
|
<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
|
```bash Terminal
|
||||||
cd nhost-vue-quickstart && npm install @nhost/vue
|
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 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:
|
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.
|
Run the following command in your terminal to create a React application using Vite.
|
||||||
|
|
||||||
```bash Terminal
|
```bash Terminal
|
||||||
npx create-next-app@latest --no-eslint \
|
npx create-next-app@next-14 --no-eslint \
|
||||||
--src-dir \
|
--src-dir \
|
||||||
--no-tailwind \
|
--no-tailwind \
|
||||||
--import-alias "@/*" \
|
--import-alias "@/*" \
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ In this section, you will create and setup your first Nhost project.
|
|||||||
|
|
||||||
### Create 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:
|
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 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:
|
Enter the details for your project and wait a couple of minutes while Nhost provisions your backend infrastructure:
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/docs",
|
"name": "@nhost/docs",
|
||||||
"version": "2.24.0",
|
"version": "2.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "mintlify dev"
|
"start": "mintlify dev"
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @nhost-examples/cli
|
# @nhost-examples/cli
|
||||||
|
|
||||||
|
## 0.3.15
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/nhost-js@3.2.2
|
||||||
|
|
||||||
## 0.3.14
|
## 0.3.14
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/cli",
|
"name": "@nhost-examples/cli",
|
||||||
"version": "0.3.14",
|
"version": "0.3.15",
|
||||||
"main": "src/index.mjs",
|
"main": "src/index.mjs",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# @nhost-examples/codegen-react-apollo
|
# @nhost-examples/codegen-react-apollo
|
||||||
|
|
||||||
|
## 0.4.16
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
- @nhost/react-apollo@15.0.1
|
||||||
|
|
||||||
## 0.4.15
|
## 0.4.15
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/codegen-react-apollo",
|
"name": "@nhost-examples/codegen-react-apollo",
|
||||||
"version": "0.4.15",
|
"version": "0.4.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"codegen": "graphql-codegen",
|
"codegen": "graphql-codegen",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @nhost-examples/codegen-react-query
|
# @nhost-examples/codegen-react-query
|
||||||
|
|
||||||
|
## 0.4.16
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
|
||||||
## 0.4.15
|
## 0.4.15
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/codegen-react-query",
|
"name": "@nhost-examples/codegen-react-query",
|
||||||
"version": "0.4.15",
|
"version": "0.4.16",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"codegen": "graphql-codegen",
|
"codegen": "graphql-codegen",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# @nhost-examples/react-urql
|
# @nhost-examples/react-urql
|
||||||
|
|
||||||
|
## 0.3.16
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
- @nhost/react-urql@12.0.1
|
||||||
|
|
||||||
## 0.3.15
|
## 0.3.15
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/codegen-react-urql",
|
"name": "@nhost-examples/codegen-react-urql",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.3.15",
|
"version": "0.3.16",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @nhost-examples/multi-tenant-one-to-many
|
# @nhost-examples/multi-tenant-one-to-many
|
||||||
|
|
||||||
|
## 2.2.16
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/nhost-js@3.2.2
|
||||||
|
|
||||||
## 2.2.15
|
## 2.2.15
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/multi-tenant-one-to-many",
|
"name": "@nhost-examples/multi-tenant-one-to-many",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "2.2.15",
|
"version": "2.2.16",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {},
|
"scripts": {},
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# @nhost-examples/nextjs
|
# @nhost-examples/nextjs
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- 29d27e1: chore: update `next` to v14.2.22 to fix vulnerabilities
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- Updated dependencies [46fc520]
|
||||||
|
- Updated dependencies [29d27e1]
|
||||||
|
- @nhost/nextjs@2.2.0
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
- @nhost/react-apollo@15.0.1
|
||||||
|
|
||||||
## 0.3.15
|
## 0.3.15
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/nextjs",
|
"name": "@nhost-examples/nextjs",
|
||||||
"version": "0.3.15",
|
"version": "0.4.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@nhost/react": "workspace:^",
|
"@nhost/react": "workspace:^",
|
||||||
"@nhost/react-apollo": "workspace:^",
|
"@nhost/react-apollo": "workspace:^",
|
||||||
"graphql": "16.8.1",
|
"graphql": "16.8.1",
|
||||||
"next": "^14.2.10",
|
"next": "^14.2.22",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-icons": "^4.12.0"
|
"react-icons": "^4.12.0"
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @nhost-examples/node-storage
|
# @nhost-examples/node-storage
|
||||||
|
|
||||||
|
## 0.2.15
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/nhost-js@3.2.2
|
||||||
|
|
||||||
## 0.2.14
|
## 0.2.14
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/node-storage",
|
"name": "@nhost-examples/node-storage",
|
||||||
"version": "0.2.14",
|
"version": "0.2.15",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "This is an example of how to use the Storage with Node.js",
|
"description": "This is an example of how to use the Storage with Node.js",
|
||||||
"main": "src/index.mjs",
|
"main": "src/index.mjs",
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# @nhost-examples/nextjs-server-components
|
# @nhost-examples/nextjs-server-components
|
||||||
|
|
||||||
|
## 0.5.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- b944d05: chore: simplify Nhost client initialization with session and remove xstate dependency
|
||||||
|
- 29d27e1: chore: update `next` to v14.2.22 to fix vulnerabilities
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/nhost-js@3.2.2
|
||||||
|
|
||||||
## 0.4.16
|
## 0.4.16
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/nextjs-server-components",
|
"name": "@nhost-examples/nextjs-server-components",
|
||||||
"version": "0.4.16",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -18,14 +18,13 @@
|
|||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"graphql": "16.8.1",
|
"graphql": "16.8.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^14.2.10",
|
"next": "^14.2.22",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss": "3.3.3",
|
"tailwindcss": "3.3.3",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2"
|
||||||
"xstate": "^4.38.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { AuthErrorPayload, NhostClient, NhostSession } from '@nhost/nhost-js'
|
import { AuthErrorPayload, NhostClient, NhostSession } from '@nhost/nhost-js'
|
||||||
import { cookies } from 'next/headers'
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { type StateFrom } from 'xstate/lib/types'
|
|
||||||
import { waitFor } from 'xstate/lib/waitFor'
|
|
||||||
|
|
||||||
export const NHOST_SESSION_KEY = 'nhostSession'
|
export const NHOST_SESSION_KEY = 'nhostSession'
|
||||||
|
|
||||||
export const getNhost = async (request?: NextRequest) => {
|
export const getNhost = async (request?: NextRequest) => {
|
||||||
@@ -20,9 +16,7 @@ export const getNhost = async (request?: NextRequest) => {
|
|||||||
const sessionCookieValue = $cookies.get(NHOST_SESSION_KEY)?.value || ''
|
const sessionCookieValue = $cookies.get(NHOST_SESSION_KEY)?.value || ''
|
||||||
const initialSession: NhostSession = JSON.parse(atob(sessionCookieValue) || 'null')
|
const initialSession: NhostSession = JSON.parse(atob(sessionCookieValue) || 'null')
|
||||||
|
|
||||||
nhost.auth.client.start({ initialSession })
|
await nhost.auth.initWithSession({ session: initialSession })
|
||||||
await waitFor(nhost.auth.client.interpreter!, (state: StateFrom<any>) => !state.hasTag('loading'))
|
|
||||||
|
|
||||||
return nhost
|
return nhost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# @nhost-examples/react-apollo
|
# @nhost-examples/react-apollo
|
||||||
|
|
||||||
|
## 1.1.2
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
- @nhost/react-apollo@15.0.1
|
||||||
|
|
||||||
## 1.1.1
|
## 1.1.1
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/react-apollo",
|
"name": "@nhost-examples/react-apollo",
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# @nhost-examples/react-gqty
|
# @nhost-examples/react-gqty
|
||||||
|
|
||||||
|
## 1.2.16
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
|
||||||
## 1.2.15
|
## 1.2.15
|
||||||
|
|
||||||
### Patch Changes
|
### Patch Changes
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost-examples/react-gqty",
|
"name": "@nhost-examples/react-gqty",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.15",
|
"version": "1.2.16",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# @nhost-examples/react-native
|
# @nhost-examples/react-native
|
||||||
|
|
||||||
|
## 0.1.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- @nhost/react@3.8.1
|
||||||
|
- @nhost/react-apollo@15.0.1
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user