Compare commits
14 Commits
@nhost/das
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3e200c55a | ||
|
|
8fb3064eea | ||
|
|
e5f1c6cb78 | ||
|
|
02994ee4e2 | ||
|
|
74a1239cd5 | ||
|
|
e32528bde5 | ||
|
|
ff4f210204 | ||
|
|
2fa9db428e | ||
|
|
6b9b2e4e6a | ||
|
|
61a8c6930f | ||
|
|
8f169885f7 | ||
|
|
6104e72204 | ||
|
|
65ca5deb4c | ||
|
|
e42832a012 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.0.5",
|
||||
"version": "2.1.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -18,6 +18,7 @@ import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
||||
import { OrgStatus } from '@/features/orgs/components/OrgStatus';
|
||||
import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy';
|
||||
import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoundRedirect';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import {
|
||||
@@ -90,7 +91,7 @@ export default function AuthenticatedLayout({
|
||||
|
||||
<Container
|
||||
rootClassName="h-full"
|
||||
className="grid justify-center max-w-md grid-flow-row gap-2 my-12 text-center"
|
||||
className="my-12 grid max-w-md grid-flow-row justify-center gap-2 text-center"
|
||||
>
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
@@ -127,18 +128,23 @@ export default function AuthenticatedLayout({
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseLayout className="flex flex-col h-full" {...props}>
|
||||
<BaseLayout className="flex h-full flex-col" {...props}>
|
||||
<Header className="flex py-1" />
|
||||
|
||||
<div
|
||||
className="relative flex flex-row h-full overflow-x-hidden"
|
||||
className="relative flex h-full flex-row overflow-hidden"
|
||||
ref={setMainNavContainer}
|
||||
>
|
||||
{mainNavPinned && isMdOrLarger && <PinnedMainNav />}
|
||||
|
||||
<div className="relative flex flex-row w-full h-full bg-accent">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full w-full flex-row bg-accent',
|
||||
mainNavPinned && isMdOrLarger ? 'overflow-x-auto' : '',
|
||||
)}
|
||||
>
|
||||
{(!mainNavPinned || !isMdOrLarger) && (
|
||||
<div className="flex justify-center w-6 h-full">
|
||||
<div className="flex h-full w-6 justify-center">
|
||||
<MainNav container={mainNavContainer} />
|
||||
</div>
|
||||
)}
|
||||
@@ -148,7 +154,7 @@ export default function AuthenticatedLayout({
|
||||
className: 'flex flex-col items-center',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex h-full w-full flex-col overflow-auto">
|
||||
<OrgStatus />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default function PinnedMainNav() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full border-r p-0 sm:max-w-[310px]">
|
||||
<div className="flex h-full w-full flex-shrink-0 flex-col border-r p-0 sm:max-w-[310px]">
|
||||
<div className="flex justify-end w-full h-12 p-1 border-b bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function TransferProject() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -18,7 +20,7 @@ export default function TransferProject() {
|
||||
type: 'button',
|
||||
color: 'primary',
|
||||
variant: 'contained',
|
||||
disabled: maintenanceActive,
|
||||
disabled: maintenanceActive || !isPlatform,
|
||||
onClick: () => setOpen(true),
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -24,13 +24,16 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useBillingTransferAppMutation } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
Organization_Members_Role_Enum,
|
||||
useBillingTransferAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
@@ -48,10 +51,10 @@ export default function TransferProjectDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: TransferProjectDialogProps) {
|
||||
const { orgs } = useOrgs();
|
||||
const { org: currentOrg } = useCurrentOrg();
|
||||
const { project } = useProject();
|
||||
const { push } = useRouter();
|
||||
const currentUserId = useUserId();
|
||||
const { project } = useProject();
|
||||
const { orgs, currentOrg } = useOrgs();
|
||||
const [transferProject] = useBillingTransferAppMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof transferProjectFormSchema>>({
|
||||
@@ -86,16 +89,29 @@ export default function TransferProjectDialog({
|
||||
);
|
||||
};
|
||||
|
||||
const isUserAdminOfOrg = (org: Org, userId: string) =>
|
||||
org.members.some(
|
||||
(member) =>
|
||||
member.role === Organization_Members_Role_Enum.Admin &&
|
||||
member.user.id === userId,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
form.reset();
|
||||
setOpen(value);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="text-foreground sm:max-w-xl">
|
||||
<DialogHeader className="flex gap-2">
|
||||
<DialogTitle>
|
||||
Move the current project to a different organization.
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
To transfer a project between two organizations, you must be ADMIN
|
||||
in both.
|
||||
To transfer a project between organizations, you must be an{' '}
|
||||
<span className="font-bold">ADMIN</span> in both.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
@@ -120,7 +136,11 @@ export default function TransferProjectDialog({
|
||||
<SelectItem
|
||||
key={org.id}
|
||||
value={org.id}
|
||||
disabled={org.plan.isFree || org.id === currentOrg.id}
|
||||
disabled={
|
||||
org.plan.isFree || // disable the personal org
|
||||
org.id === currentOrg.id || // disable the current org as it can't be a destination org
|
||||
!isUserAdminOfOrg(org, currentUserId) // disable orgs that the current user is not admin of
|
||||
}
|
||||
>
|
||||
{org.name}
|
||||
<Badge
|
||||
@@ -146,11 +166,19 @@ export default function TransferProjectDialog({
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
form.formState.isSubmitting || !form.formState.isDirty
|
||||
}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
|
||||
@@ -34,9 +34,15 @@ import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function NotificationsTray() {
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const { asPath, route } = useRouter();
|
||||
const userData = useUserData();
|
||||
const { asPath, route } = useRouter();
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
|
||||
const [stripeFormDialogOpen, setStripeFormDialogOpen] = useState(false);
|
||||
|
||||
const [pendingOrgRequest, setPendingOrgRequest] =
|
||||
useState<PostOrganizationRequestResponse | null>(null);
|
||||
|
||||
const [
|
||||
getInvites,
|
||||
{
|
||||
@@ -45,11 +51,6 @@ export default function NotificationsTray() {
|
||||
data: { organizationMemberInvites: invites = [] } = {},
|
||||
},
|
||||
] = useOrganizationMemberInvitesLazyQuery();
|
||||
|
||||
const [stripeFormDialogOpen, setStripeFormDialogOpen] = useState(false);
|
||||
|
||||
const [pendingOrgRequest, setPendingOrgRequest] =
|
||||
useState<PostOrganizationRequestResponse | null>(null);
|
||||
const [getOrganizationNewRequests] = useOrganizationNewRequestsLazyQuery();
|
||||
const [postOrganizationRequest] = usePostOrganizationRequestMutation();
|
||||
|
||||
@@ -155,12 +156,11 @@ export default function NotificationsTray() {
|
||||
<>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" className="relative px-3 py-1 h-fit">
|
||||
<Button variant="ghost" className="relative h-fit px-3 py-1">
|
||||
<Bell className="mt-[2px] h-[1.15rem] w-[1.15rem]" />
|
||||
{invites.length > 0 ||
|
||||
(pendingOrgRequest && (
|
||||
<div className="absolute w-2 h-2 bg-red-500 rounded-full right-3 top-2" />
|
||||
))}
|
||||
{(pendingOrgRequest || Boolean(invites.length)) && (
|
||||
<div className="absolute right-3 top-2 h-2 w-2 rounded-full bg-red-500" />
|
||||
)}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="h-full w-full bg-background p-0 text-foreground sm:max-w-[310px]">
|
||||
@@ -170,14 +170,14 @@ export default function NotificationsTray() {
|
||||
List of pending invites and create organization requests
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex flex-col w-full h-full">
|
||||
<div className="flex items-center h-12 px-2 border-b">
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<div className="flex h-12 items-center border-b px-2">
|
||||
<h3 className="font-medium">
|
||||
Notifications {invites.length > 0 && `(${invites.length})`}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-2">
|
||||
<div className="flex h-full flex-col gap-2 overflow-auto p-2">
|
||||
{!loading && invites.length === 0 && !pendingOrgRequest && (
|
||||
<span className="text-muted-foreground">
|
||||
No new notifications
|
||||
@@ -185,9 +185,9 @@ export default function NotificationsTray() {
|
||||
)}
|
||||
|
||||
{pendingOrgRequest && (
|
||||
<div className="flex flex-col gap-2 p-2 border rounded-md">
|
||||
<div className="flex flex-col gap-2 rounded-md border p-2">
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Badge className="h-5 px-[6px] text-[10px]">
|
||||
New Organization pending
|
||||
</Badge>
|
||||
@@ -212,10 +212,10 @@ export default function NotificationsTray() {
|
||||
{invites.map((invite) => (
|
||||
<div
|
||||
key={invite.id}
|
||||
className="flex flex-col gap-2 p-2 border rounded-md"
|
||||
className="flex flex-col gap-2 rounded-md border p-2"
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<Badge className="h-5 px-[6px] text-[10px]">
|
||||
Invitation
|
||||
</Badge>
|
||||
@@ -265,7 +265,7 @@ export default function NotificationsTray() {
|
||||
onOpenChange={setStripeFormDialogOpen}
|
||||
>
|
||||
<DialogContent
|
||||
className="text-black bg-white sm:max-w-xl"
|
||||
className="bg-white text-black sm:max-w-xl"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function ErrorToast({
|
||||
style={{
|
||||
backgroundColor: getToastBackgroundColor(),
|
||||
}}
|
||||
className="flex w-full max-w-xl flex-col space-y-4 rounded-lg p-4 text-white"
|
||||
className="flex flex-col w-full max-w-xl p-4 space-y-4 text-white rounded-lg"
|
||||
initial={{
|
||||
opacity: 0,
|
||||
y: 100,
|
||||
@@ -112,24 +112,30 @@ export default function ErrorToast({
|
||||
bounce: 0.1,
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-between space-x-4">
|
||||
<button onClick={close} type="button" aria-label="Close">
|
||||
<XIcon className="h-4 w-4 text-white" />
|
||||
<div className="flex flex-row items-center justify-between w-full gap-4">
|
||||
<button
|
||||
className="flex-shrink-0"
|
||||
onClick={close}
|
||||
type="button"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon className="w-4 h-4 text-white" />
|
||||
</button>
|
||||
<span>
|
||||
<span className="flex-grow overflow-hidden break-words">
|
||||
{msg ?? 'An unkown error has occured, please try again later!'}
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowInfo(!showInfo)}
|
||||
className="flex flex-row items-center justify-center space-x-2 text-white"
|
||||
className="flex flex-row items-center justify-center flex-shrink-0 space-x-2 text-white"
|
||||
aria-label="Show error details"
|
||||
>
|
||||
<span>Info</span>
|
||||
{showInfo ? (
|
||||
<ChevronUpIcon className="h-3 w-3 text-white" />
|
||||
<ChevronUpIcon className="w-3 h-3 text-white" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-3 w-3 text-white" />
|
||||
<ChevronDownIcon className="w-3 h-3 text-white" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -152,7 +158,7 @@ export default function ErrorToast({
|
||||
);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function SettingsLayout({ children }: SettingsLayoutProps) {
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex flex-col flex-auto w-full overflow-x-hidden overflow-y-auto"
|
||||
className="flex flex-col flex-auto w-full h-full overflow-x-hidden overflow-y-auto"
|
||||
>
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
|
||||
@@ -2,11 +2,9 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowRightIcon } from '@/components/ui/v2/icons/ArrowRightIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useDeleteApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useBillingDeleteAppMutation } from '@/generated/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { getApplicationStatusString } from '@/utils/helpers';
|
||||
@@ -14,17 +12,22 @@ import { formatDistance } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
export default function ApplicationInfo() {
|
||||
const { project } = useProject();
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
refetchQueries: [{ query: GetAllWorkspacesAndProjectsDocument }],
|
||||
});
|
||||
const router = useRouter();
|
||||
const { project } = useProject();
|
||||
const { currentOrg: org } = useOrgs();
|
||||
|
||||
const [deleteApplication] = useBillingDeleteAppMutation();
|
||||
|
||||
async function handleClickRemove() {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await deleteApplication({ variables: { appId: project.id } });
|
||||
await router.push('/');
|
||||
await deleteApplication({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
},
|
||||
});
|
||||
|
||||
await router.push(`/orgs/${org?.slug}/projects`);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Deleting project...',
|
||||
@@ -40,7 +43,7 @@ export default function ApplicationInfo() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-4 mt-4">
|
||||
<div className="mt-4 grid grid-flow-row gap-4">
|
||||
<div className="grid grid-flow-row justify-center gap-0.5">
|
||||
<Text variant="subtitle2">Application ID:</Text>
|
||||
|
||||
@@ -88,7 +91,7 @@ export default function ApplicationInfo() {
|
||||
href={`https://staging.nhost.run/console/data/default/schema/public/tables/app_state_history/browse?filter=app_id%3B%24eq%3B${project.id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="grid items-center justify-center grid-flow-col gap-1 p-2"
|
||||
className="grid grid-flow-col items-center justify-center gap-1 p-2"
|
||||
underline="hover"
|
||||
>
|
||||
App State History <ArrowRightIcon />
|
||||
|
||||
@@ -42,6 +42,9 @@ export default function ApplicationPaused() {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await unpauseApplication({ variables: { appId: project.id } });
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
await refetchProject();
|
||||
},
|
||||
{
|
||||
@@ -62,17 +65,19 @@ export default function ApplicationPaused() {
|
||||
<Modal
|
||||
showModal={showDeletingModal}
|
||||
close={() => setShowDeletingModal(false)}
|
||||
className="flex h-screen items-center justify-center"
|
||||
>
|
||||
<RemoveApplicationModal
|
||||
close={() => setShowDeletingModal(false)}
|
||||
title={`Remove project ${project.name}?`}
|
||||
description={`The project ${project.name} will be removed. All data will be lost and there will be no way to
|
||||
recover the app once it has been deleted.`}
|
||||
className="z-50"
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Container className="grid max-w-lg grid-flow-row gap-6 mx-auto text-center">
|
||||
<div className="flex flex-col mx-auto text-center w-centImage">
|
||||
<Container className="mx-auto grid max-w-lg grid-flow-row gap-6 text-center">
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<ApplicationPausedSymbol isLocked={isLocked} />
|
||||
</div>
|
||||
|
||||
@@ -93,7 +98,7 @@ export default function ApplicationPaused() {
|
||||
{org && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full max-w-xs mx-auto"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
onClick={() => setTransferProjectDialogOpen(true)}
|
||||
>
|
||||
Transfer
|
||||
@@ -106,7 +111,7 @@ export default function ApplicationPaused() {
|
||||
)}
|
||||
<Button
|
||||
variant="borderless"
|
||||
className="w-full max-w-xs mx-auto"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
loading={changingApplicationStateLoading}
|
||||
disabled={
|
||||
changingApplicationStateLoading ||
|
||||
@@ -121,7 +126,7 @@ export default function ApplicationPaused() {
|
||||
<Button
|
||||
color="error"
|
||||
variant="outlined"
|
||||
className="w-full max-w-xs mx-auto"
|
||||
className="mx-auto w-full max-w-xs"
|
||||
onClick={() => setShowDeletingModal(true)}
|
||||
>
|
||||
Delete Project
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { ApplicationInfo } from '@/features/projects/common/components/ApplicationInfo';
|
||||
import { AppLoader } from '@/features/projects/common/components/AppLoader';
|
||||
import { StagingMetadata } from '@/features/projects/common/components/StagingMetadata';
|
||||
import { useCheckProvisioning } from '@/features/projects/common/hooks/useCheckProvisioning';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { ApplicationInfo } from '@/features/orgs/projects/common/components/ApplicationInfo';
|
||||
import { AppLoader } from '@/features/orgs/projects/common/components/AppLoader';
|
||||
import { StagingMetadata } from '@/features/orgs/projects/common/components/StagingMetadata';
|
||||
import { useCheckProvisioning } from '@/features/orgs/projects/common/hooks/useCheckProvisioning';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function ApplicationRestoring() {
|
||||
const { project } = useProject();
|
||||
const currentProjectState = useCheckProvisioning();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
return (
|
||||
<Container className="mx-auto mt-8 grid max-w-sm grid-flow-row gap-4 text-center">
|
||||
@@ -26,7 +26,7 @@ export default function ApplicationRestoring() {
|
||||
{currentProjectState.state === ApplicationStatus.Empty ? (
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h1">
|
||||
Setting Up {currentProject?.name}
|
||||
Setting Up {project?.name}
|
||||
</Text>
|
||||
|
||||
<Text>This normally takes around 2 minutes</Text>
|
||||
|
||||
@@ -3,13 +3,11 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useDeleteApplicationMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useBillingDeleteAppMutation } from '@/utils/__generated__/graphql';
|
||||
import router from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
@@ -44,14 +42,14 @@ export default function RemoveApplicationModal({
|
||||
description,
|
||||
className,
|
||||
}: RemoveApplicationModalProps) {
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
refetchQueries: [{ query: GetAllWorkspacesAndProjectsDocument }],
|
||||
});
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const { project } = useProject();
|
||||
const { currentOrg: org } = useOrgs();
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const [deleteApplication] = useBillingDeleteAppMutation();
|
||||
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [remove2, setRemove2] = useState(false);
|
||||
|
||||
const appName = project?.name;
|
||||
|
||||
async function handleClick() {
|
||||
@@ -69,14 +67,14 @@ export default function RemoveApplicationModal({
|
||||
try {
|
||||
await deleteApplication({
|
||||
variables: {
|
||||
appId: project.id,
|
||||
appID: project?.id,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await discordAnnounce(`Error trying to delete project: ${appName}`);
|
||||
}
|
||||
close();
|
||||
await router.push('/');
|
||||
await router.push(`/orgs/${org?.slug}/projects`);
|
||||
triggerToast(`${project.name} deleted`);
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,6 @@ export default function useProject({
|
||||
project: localApplication,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch,
|
||||
refetch: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function ServiceForm({
|
||||
memory: 128,
|
||||
},
|
||||
replicas: 1,
|
||||
autoscaler: null,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
@@ -98,7 +99,7 @@ export default function ServiceForm({
|
||||
return uuidv4();
|
||||
}, [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';
|
||||
|
||||
@@ -166,6 +167,11 @@ export default function ServiceForm({
|
||||
capacity: item.capacity,
|
||||
})),
|
||||
replicas: sanitizedValues.replicas,
|
||||
autoscaler: sanitizedValues.autoscaler
|
||||
? {
|
||||
maxReplicas: sanitizedValues.autoscaler?.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
environment: sanitizedValues.environment.map((item) => ({
|
||||
name: item.name,
|
||||
@@ -329,7 +335,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Name of the service, must be unique per project.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -353,7 +359,7 @@ export default function ServiceForm({
|
||||
<Tooltip title="Command to run when to start the service. This is optional as the image may already have a baked-in command.">
|
||||
<InfoIcon
|
||||
aria-label="Info"
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
color="primary"
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -408,7 +414,7 @@ export default function ServiceForm({
|
||||
{createServiceFormError && (
|
||||
<Alert
|
||||
severity="error"
|
||||
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||
className="grid items-center justify-between grid-flow-col px-4 py-3"
|
||||
>
|
||||
<span className="text-left">
|
||||
<strong>Error:</strong> {createServiceFormError.message}
|
||||
|
||||
@@ -26,6 +26,12 @@ export const validationSchema = Yup.object({
|
||||
memory: Yup.number().min(MIN_SERVICES_MEM).max(MAX_SERVICES_MEM).required(),
|
||||
}),
|
||||
replicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS).required(),
|
||||
autoscaler: Yup.object()
|
||||
.shape({
|
||||
maxReplicas: Yup.number().min(0).max(MAX_SERVICE_REPLICAS),
|
||||
})
|
||||
.nullable()
|
||||
.default(undefined),
|
||||
ports: Yup.array().of(
|
||||
Yup.object().shape({
|
||||
port: Yup.number().required(),
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function ComputeFormSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
vCPUs: {formValues.compute.cpu / 1000} / Memory:{' '}
|
||||
@@ -70,7 +70,7 @@ export default function ComputeFormSection({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
href="https://docs.nhost.io/guides/run/resources#compute"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
@@ -79,7 +79,7 @@ export default function ComputeFormSection({
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -90,7 +90,7 @@ export default function ComputeFormSection({
|
||||
variant="outlined"
|
||||
onClick={decrementCompute}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Slider
|
||||
@@ -107,7 +107,7 @@ export default function ComputeFormSection({
|
||||
variant="outlined"
|
||||
onClick={incrementCompute}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import { PortTypes } from '@/features/services/components/ServiceForm/components/PortsFormSection/PortsFormSectionTypes';
|
||||
import { type ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
@@ -19,7 +19,7 @@ import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
export default function PortsFormSection() {
|
||||
const form = useFormContext<ServiceFormValues>();
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { project } = useProject();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -38,12 +38,13 @@ export default function PortsFormSection() {
|
||||
|
||||
const showURL = (index: number) =>
|
||||
formValues.subdomain &&
|
||||
formValues.ports[index]?.type === PortTypes.HTTP &&
|
||||
(formValues.ports[index]?.type === PortTypes.HTTP ||
|
||||
formValues.ports[index]?.type === PortTypes.GRPC) &&
|
||||
formValues.ports[index]?.publish;
|
||||
|
||||
return (
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="flex flex-row items-center justify-between">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Ports
|
||||
@@ -106,7 +107,7 @@ export default function PortsFormSection() {
|
||||
},
|
||||
}}
|
||||
>
|
||||
{['http', 'tcp', 'udp']?.map((portType) => (
|
||||
{['http', 'tcp', 'udp', 'grpc']?.map((portType) => (
|
||||
<Option key={portType} value={portType}>
|
||||
{portType}
|
||||
</Option>
|
||||
@@ -137,8 +138,8 @@ export default function PortsFormSection() {
|
||||
title="URL"
|
||||
value={getRunServicePortURL(
|
||||
formValues?.subdomain,
|
||||
currentProject?.region.name,
|
||||
currentProject?.region.domain,
|
||||
project?.region.name,
|
||||
project?.region.domain,
|
||||
formValues.ports[index],
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { InfoIcon } from '@/components/ui/v2/icons/InfoIcon';
|
||||
import { Slider } from '@/components/ui/v2/Slider';
|
||||
import { InfoOutlinedIcon } from '@/components/ui/v2/icons/InfoOutlinedIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { MAX_SERVICE_REPLICAS } from '@/features/projects/resources/settings/utils/resourceSettingsValidationSchema';
|
||||
import type { ServiceFormValues } from '@/features/services/components/ServiceForm/ServiceFormTypes';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export default function ReplicasFormSection() {
|
||||
const { setValue } = useFormContext<ServiceFormValues>();
|
||||
const {
|
||||
register,
|
||||
setValue,
|
||||
trigger: triggerValidation,
|
||||
} = useFormContext<ServiceFormValues>();
|
||||
const { replicas, autoscaler } = useWatch<ServiceFormValues>();
|
||||
const [autoscalerEnabled, setAutoscalerEnabled] = useState(!!autoscaler);
|
||||
|
||||
const { replicas } = useWatch<ServiceFormValues>();
|
||||
const toggleAutoscalerEnabled = async (enabled: boolean) => {
|
||||
setAutoscalerEnabled(enabled);
|
||||
|
||||
if (!enabled) {
|
||||
setValue('autoscaler', null);
|
||||
} else {
|
||||
setValue('autoscaler.maxReplicas', 10);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplicasChange = (value: string) => {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
@@ -20,42 +36,95 @@ export default function ReplicasFormSection() {
|
||||
// TODO Trigger revalidate storage
|
||||
};
|
||||
|
||||
const handleMaxReplicasChange = (value: string) => {
|
||||
const updatedReplicas = parseInt(value, 10);
|
||||
|
||||
setValue('autoscaler.maxReplicas', updatedReplicas, { shouldDirty: true });
|
||||
|
||||
triggerValidation('autoscaler.maxReplicas');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Replicas ({replicas})
|
||||
</Text>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Number of replicas for the service. Multiple replicas can process
|
||||
requests/work in parallel. You can set replicas to 0 to pause the
|
||||
service. Refer to{' '}
|
||||
<Text className="text-white">
|
||||
Learn more about{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/resources"
|
||||
href="https://docs.nhost.io/platform/service-replicas"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
</a>{' '}
|
||||
for more information.
|
||||
</span>
|
||||
Service Replicas
|
||||
</a>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
<Slider
|
||||
value={replicas}
|
||||
onChange={(_event, value) => handleReplicasChange(value.toString())}
|
||||
min={0}
|
||||
max={MAX_SERVICE_REPLICAS}
|
||||
step={1}
|
||||
aria-label="Replicas"
|
||||
marks
|
||||
/>
|
||||
|
||||
<Box className="flex flex-col justify-between gap-4 lg:flex-row">
|
||||
<Box className="flex flex-col gap-4 lg:flex-row lg:gap-8">
|
||||
<Box className="flex flex-row items-center gap-2">
|
||||
<Text className="w-28 lg:w-auto">Replicas</Text>
|
||||
<Input
|
||||
{...register('replicas')}
|
||||
onChange={(event) => handleReplicasChange(event.target.value)}
|
||||
type="number"
|
||||
id="replicas"
|
||||
placeholder="Replicas"
|
||||
className="max-w-28"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
onWheel={(e) => (e.target as HTMLInputElement).blur()}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box className="flex flex-row items-center gap-2">
|
||||
<Text className="w-28 text-nowrap lg:w-auto">Max Replicas</Text>
|
||||
<Input
|
||||
value={autoscaler?.maxReplicas}
|
||||
onChange={(event) => handleMaxReplicasChange(event.target.value)}
|
||||
type="number"
|
||||
id="maxReplicas"
|
||||
placeholder="10"
|
||||
disabled={!autoscalerEnabled}
|
||||
className="max-w-28"
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
onWheel={(e) => (e.target as HTMLInputElement).blur()}
|
||||
autoComplete="off"
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
min: 0,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className="flex flex-row items-center gap-3">
|
||||
<Switch
|
||||
checked={autoscalerEnabled}
|
||||
onChange={(e) => toggleAutoscalerEnabled(e.target.checked)}
|
||||
className="self-center"
|
||||
/>
|
||||
<Text>Autoscaler</Text>
|
||||
<Tooltip title="Enable autoscaler to automatically provision extra run service replicas when needed.">
|
||||
<InfoOutlinedIcon className="w-4 h-4 text-black" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,8 +41,8 @@ export default function StorageFormSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Storage
|
||||
@@ -58,7 +58,7 @@ export default function StorageFormSection() {
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/storage"
|
||||
href="https://docs.nhost.io/guides/run/resources#storage"
|
||||
className="underline"
|
||||
>
|
||||
Storage
|
||||
@@ -67,7 +67,7 @@ export default function StorageFormSection() {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function StorageFormSection() {
|
||||
variant="borderless"
|
||||
onClick={() => append({ name: '', capacity: 1, path: '' })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function StorageFormSection() {
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -50,7 +50,7 @@ export default function ServicesList({
|
||||
openDrawer({
|
||||
title: (
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<CubeIcon className="w-5 h-5" />
|
||||
<Text>Edit {service.config?.name ?? 'unset'}</Text>
|
||||
</Box>
|
||||
),
|
||||
@@ -75,6 +75,7 @@ export default function ServicesList({
|
||||
memory: 128,
|
||||
},
|
||||
replicas: service.config?.resources?.replicas,
|
||||
autoscaler: service?.config?.resources?.autoscaler,
|
||||
storage: service.config?.resources?.storage,
|
||||
}}
|
||||
onSubmit={() => onCreateOrUpdate()}
|
||||
@@ -109,13 +110,13 @@ export default function ServicesList({
|
||||
onClick={() => viewService(service)}
|
||||
>
|
||||
<Box
|
||||
className="flex w-full flex-row justify-between"
|
||||
className="flex flex-row justify-between w-full"
|
||||
sx={{
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-row items-center space-x-4">
|
||||
<CubeIcon className="h-5 w-5" />
|
||||
<div className="flex flex-row items-center flex-1 space-x-4">
|
||||
<CubeIcon className="w-5 h-5" />
|
||||
<div className="flex flex-col">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
{service.config?.name ?? 'unset'}
|
||||
@@ -131,7 +132,7 @@ export default function ServicesList({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden flex-row items-center space-x-2 md:flex">
|
||||
<div className="flex-row items-center hidden space-x-2 md:flex">
|
||||
<Text variant="subtitle1" className="font-mono text-xs">
|
||||
{service.id ?? service.serviceID}
|
||||
</Text>
|
||||
@@ -144,7 +145,7 @@ export default function ServicesList({
|
||||
}}
|
||||
aria-label="Service Id"
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
<CopyIcon className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</Box>
|
||||
@@ -174,7 +175,7 @@ export default function ServicesList({
|
||||
onClick={() => viewService(service)}
|
||||
className="z-50 grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<UserIcon className="w-4 h-4" />
|
||||
<Text className="font-medium">View Service</Text>
|
||||
</Dropdown.Item>
|
||||
<Divider component="li" />
|
||||
@@ -187,7 +188,7 @@ export default function ServicesList({
|
||||
}}
|
||||
disabled={!isPlatform}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
<Text className="font-medium" color="error">
|
||||
Delete Service
|
||||
</Text>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Column } from 'react-table';
|
||||
import { expect, test } from 'vitest';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
interface MockDataDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const mockColumns: Column<MockDataDetails>[] = [
|
||||
{ id: 'id', Header: 'ID', accessor: 'id' },
|
||||
{ id: 'name', Header: 'Name', accessor: 'name' },
|
||||
];
|
||||
|
||||
const mockData: MockDataDetails[] = [
|
||||
{ id: 1, name: 'foo' },
|
||||
{ id: 2, name: 'bar' },
|
||||
];
|
||||
|
||||
test('should render an empty state if columns are not available', () => {
|
||||
render(<DataGrid columns={[]} data={[]} />);
|
||||
|
||||
expect(screen.getByText(/columns not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render columns and empty state message if data is unavailable', () => {
|
||||
render(<DataGrid columns={mockColumns} data={[]} />);
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('columnheader', { name: /id/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/no data is available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render custom empty state message if data is unavailable', () => {
|
||||
const customEmptyStateMessage = 'custom empty state message';
|
||||
|
||||
render(
|
||||
<DataGrid
|
||||
columns={mockColumns}
|
||||
data={[]}
|
||||
emptyStateMessage={customEmptyStateMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(customEmptyStateMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display a loading indicator', async () => {
|
||||
render(<DataGrid columns={mockColumns} data={[]} loading />);
|
||||
|
||||
// Activity indicator is not immediately displayed, so we need to wait
|
||||
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render data if provided', () => {
|
||||
render(<DataGrid columns={mockColumns} data={mockData} />);
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(2);
|
||||
expect(screen.getByRole('cell', { name: /1/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /foo/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { UseDataGridOptions } from '@/components/dataGrid/DataGrid/useDataGrid';
|
||||
import { DataGridBody } from '@/components/dataGrid/DataGridBody';
|
||||
import { DataGridConfigProvider } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import { DataGridFrame } from '@/components/dataGrid/DataGridFrame';
|
||||
import type { DataGridHeaderProps } from '@/components/dataGrid/DataGridHeader';
|
||||
import { DataGridHeader } from '@/components/dataGrid/DataGridHeader';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { DataBrowserEmptyState } from '@/features/database/dataGrid/components/DataBrowserEmptyState';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import type { Column, Row, SortingRule, TableOptions } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import useDataGrid from './useDataGrid';
|
||||
|
||||
export interface DataGridProps<TColumnData extends object>
|
||||
extends Omit<UseDataGridOptions<TColumnData>, 'tableRef'> {
|
||||
/**
|
||||
* Available columns.
|
||||
*/
|
||||
columns: Column<TColumnData>[];
|
||||
/**
|
||||
* Data to be displayed in the table.
|
||||
*/
|
||||
data: any[];
|
||||
/**
|
||||
* Text to be displayed when no data is available in the data grid.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
emptyStateMessage?: string;
|
||||
/**
|
||||
* Additional configuration options for the `react-table` hook.
|
||||
*/
|
||||
options?: Omit<TableOptions<TColumnData>, 'columns' | 'data'>;
|
||||
/**
|
||||
* Additional data grid controls. This component will be part of the Data Grid
|
||||
* context, so it can use Data Grid configuration.
|
||||
*/
|
||||
controls?:
|
||||
| React.ReactNode
|
||||
| ((selectedFlatRows: Row<TColumnData>[]) => React.ReactNode);
|
||||
/**
|
||||
* Function to be called when columns are sorted in the table.
|
||||
*/
|
||||
onSort?: (args: SortingRule<TColumnData>[]) => void;
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new row.
|
||||
*/
|
||||
onInsertRow?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new column.
|
||||
*/
|
||||
onInsertColumn?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the user wants to remove a column.
|
||||
*/
|
||||
onRemoveColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
|
||||
/**
|
||||
* Function to be called when the user wants to edit a column.
|
||||
*/
|
||||
onEditColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
|
||||
/**
|
||||
* Determines whether or not data is loading.
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Class name to be applied to the data grid.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Sort configuration.
|
||||
*/
|
||||
sortBy?: SortingRule<TColumnData>[];
|
||||
/**
|
||||
* Props to be passed to the `DataGridHeader` component.
|
||||
*/
|
||||
headerProps?: DataGridHeaderProps<TColumnData>;
|
||||
}
|
||||
|
||||
function DataGrid<TColumnData extends object>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
allowSelection,
|
||||
allowSort,
|
||||
allowResize,
|
||||
emptyStateMessage,
|
||||
options = {},
|
||||
headerProps,
|
||||
controls,
|
||||
sortBy,
|
||||
onSort,
|
||||
onInsertRow,
|
||||
onInsertColumn,
|
||||
onEditColumn,
|
||||
onRemoveColumn,
|
||||
loading,
|
||||
className,
|
||||
}: DataGridProps<TColumnData>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const tableRef = useRef<HTMLDivElement>();
|
||||
const { toggleAllRowsSelected, setSortBy, ...dataGridProps } =
|
||||
useDataGrid<TColumnData>({
|
||||
columns: columns || [],
|
||||
data: data || [],
|
||||
allowSelection,
|
||||
allowSort,
|
||||
allowResize,
|
||||
...options,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortBy && setSortBy) {
|
||||
setSortBy([]);
|
||||
}
|
||||
}, [setSortBy, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSort && allowSort) {
|
||||
onSort(dataGridProps.state.sortBy);
|
||||
|
||||
if (toggleAllRowsSelected) {
|
||||
toggleAllRowsSelected(false);
|
||||
}
|
||||
}
|
||||
}, [allowSort, dataGridProps.state.sortBy, onSort, toggleAllRowsSelected]);
|
||||
|
||||
return (
|
||||
<DataGridConfigProvider
|
||||
toggleAllRowsSelected={toggleAllRowsSelected}
|
||||
setSortBy={setSortBy}
|
||||
tableRef={tableRef}
|
||||
{...dataGridProps}
|
||||
>
|
||||
<>
|
||||
{controls}
|
||||
|
||||
{columns.length === 0 && !loading && (
|
||||
<DataBrowserEmptyState
|
||||
title="Columns not found"
|
||||
description="Please create a column before adding data to the table."
|
||||
/>
|
||||
)}
|
||||
|
||||
{columns.length > 0 && (
|
||||
<Box
|
||||
ref={mergeRefs([ref, tableRef])}
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className={twMerge(
|
||||
'overflow-x-auto',
|
||||
!loading && 'h-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DataGridFrame>
|
||||
<DataGridHeader
|
||||
onInsertColumn={onInsertColumn}
|
||||
onEditColumn={onEditColumn}
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
{...headerProps}
|
||||
/>
|
||||
|
||||
<DataGridBody
|
||||
emptyStateMessage={emptyStateMessage}
|
||||
loading={loading}
|
||||
onInsertRow={onInsertRow}
|
||||
allowInsertColumn={Boolean(onRemoveColumn)}
|
||||
/>
|
||||
</DataGridFrame>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading && <ActivityIndicator delay={1000} className="my-4" />}
|
||||
</>
|
||||
</DataGridConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(DataGrid) as <TColumnData extends object>(
|
||||
props: DataGridProps<TColumnData> & { ref?: ForwardedRef<HTMLDivElement> },
|
||||
) => ReturnType<typeof DataGrid>;
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './DataGrid';
|
||||
export { default as DataGrid } from './DataGrid';
|
||||
export * from './useDataGrid';
|
||||
export { default as useDataGrid } from './useDataGrid';
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { PluginHook, TableInstance, TableOptions } from 'react-table';
|
||||
import {
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useRowSelect,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
|
||||
export interface UseDataGridBaseOptions {
|
||||
/**
|
||||
* Determines whether data grid columns are selectable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowSelection?: boolean;
|
||||
/**
|
||||
* Determines whether data grid columns are sortable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowSort?: boolean;
|
||||
/**
|
||||
* Determine whether data grid columns are resizable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowResize?: boolean;
|
||||
/**
|
||||
* Reference to the data grid root element.
|
||||
*/
|
||||
tableRef?: MutableRefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export type UseDataGridOptions<T extends object = {}> = TableOptions<T> &
|
||||
UseDataGridBaseOptions;
|
||||
export type UseDataGridReturn<T extends object = {}> = TableInstance<T> &
|
||||
UseDataGridBaseOptions;
|
||||
|
||||
export default function useDataGrid<T extends object>(
|
||||
{ allowSelection, allowSort, allowResize, ...options }: UseDataGridOptions<T>,
|
||||
...plugins: PluginHook<T>[]
|
||||
): UseDataGridReturn<T> {
|
||||
const defaultColumn = useMemo(
|
||||
() => ({
|
||||
width: 32,
|
||||
minWidth: 32,
|
||||
Cell: ({ value }: { value: any }) => (
|
||||
<span className="truncate">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : value}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const pluginHooks = [
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useSortBy,
|
||||
useRowSelect,
|
||||
];
|
||||
|
||||
const tableData = useTable<T>(
|
||||
{
|
||||
defaultColumn,
|
||||
...options,
|
||||
},
|
||||
...pluginHooks,
|
||||
...plugins,
|
||||
(hooks) =>
|
||||
allowSelection
|
||||
? hooks.visibleColumns.push((columns) => [
|
||||
{
|
||||
id: 'selection',
|
||||
Header: ({ rows, getToggleAllRowsSelectedProps }: any) => (
|
||||
<Checkbox
|
||||
disabled={rows.length === 0}
|
||||
{...getToggleAllRowsSelectedProps({ style: null })}
|
||||
style={{
|
||||
...getToggleAllRowsSelectedProps().style,
|
||||
cursor: rows.length === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
Cell: ({ row }: any) => {
|
||||
const originalValue = row.original as any;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
{...row.getToggleRowSelectedProps()}
|
||||
// disable selection if row is just a upload preview
|
||||
checked={originalValue.uploading ? false : row.isSelected}
|
||||
disabled={originalValue.uploading}
|
||||
/>
|
||||
);
|
||||
},
|
||||
disableSortBy: true,
|
||||
disableResizing: true,
|
||||
},
|
||||
...columns,
|
||||
])
|
||||
: hooks.visibleColumns,
|
||||
);
|
||||
|
||||
return { ...tableData, allowSort, allowResize, allowSelection };
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps, KeyboardEvent } from 'react';
|
||||
import { Fragment, useMemo, useRef } from 'react';
|
||||
import type { Row } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DataGridBodyProps<T extends object>
|
||||
extends Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>,
|
||||
Pick<DataGridProps<T>, 'onInsertRow' | 'emptyStateMessage' | 'loading'> {
|
||||
/**
|
||||
* Determines whether column insertion is allowed.
|
||||
*/
|
||||
allowInsertColumn?: boolean;
|
||||
}
|
||||
|
||||
interface InsertPlaceholderTableRowProps extends BoxProps {
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new row.
|
||||
*/
|
||||
onInsertRow: VoidFunction;
|
||||
}
|
||||
|
||||
function InsertPlaceholderTableRow({
|
||||
onInsertRow,
|
||||
...props
|
||||
}: InsertPlaceholderTableRowProps) {
|
||||
return (
|
||||
<Box className="h-12 border-b-1 border-r-1" {...props}>
|
||||
<Button
|
||||
onClick={onInsertRow}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
startIcon={
|
||||
<PlusIcon className="h-4 w-4" sx={{ color: 'text.secondary' }} />
|
||||
}
|
||||
>
|
||||
Insert New Row
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Get rid of Data Browser related code from here. This component should
|
||||
// be generic and not depend on Data Browser related data types and logic.
|
||||
export default function DataGridBody<T extends object>({
|
||||
emptyStateMessage = 'No data is available',
|
||||
loading,
|
||||
onInsertRow,
|
||||
allowInsertColumn,
|
||||
...props
|
||||
}: DataGridBodyProps<T>) {
|
||||
const { getTableBodyProps, totalColumnsWidth, rows, prepareRow, columns } =
|
||||
useDataGridConfig<T>();
|
||||
|
||||
const SELECTION_CELL_WIDTH = 32;
|
||||
const ADD_COLUMN_CELL_WIDTH = 100;
|
||||
const bodyRef = useRef<HTMLDivElement>();
|
||||
|
||||
const primaryAndUniqueKeys = useMemo(
|
||||
() =>
|
||||
columns
|
||||
.filter(
|
||||
(column: DataBrowserGridColumn<T>) =>
|
||||
column.isPrimary || column.isUnique,
|
||||
)
|
||||
.map((column) => column.id),
|
||||
[columns],
|
||||
);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>, row: Row<T>) {
|
||||
const { id: rowId } = row;
|
||||
const cellId = document.activeElement.id;
|
||||
|
||||
const currentRow = bodyRef.current.children.namedItem(rowId);
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentRow.previousElementSibling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellInPreviousRow =
|
||||
currentRow.previousElementSibling.children.namedItem(cellId);
|
||||
|
||||
if (cellInPreviousRow instanceof HTMLElement) {
|
||||
cellInPreviousRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
cellInPreviousRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentRow.nextElementSibling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellInNextRow =
|
||||
currentRow.nextElementSibling.children.namedItem(cellId);
|
||||
|
||||
if (cellInNextRow instanceof HTMLElement) {
|
||||
cellInNextRow.scrollIntoView({ block: 'nearest' });
|
||||
cellInNextRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
|
||||
let previousFocusableCellInRow: HTMLElement;
|
||||
let previousFocusableCellInRowFound = false;
|
||||
|
||||
currentRow.childNodes.forEach((node) => {
|
||||
if (node === currentRow.children.namedItem(cellId)) {
|
||||
previousFocusableCellInRowFound = true;
|
||||
}
|
||||
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.tabIndex > -1 &&
|
||||
!previousFocusableCellInRowFound
|
||||
) {
|
||||
previousFocusableCellInRow = node;
|
||||
}
|
||||
});
|
||||
|
||||
if (previousFocusableCellInRow) {
|
||||
event.preventDefault();
|
||||
|
||||
previousFocusableCellInRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
previousFocusableCellInRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'ArrowRight' ||
|
||||
(!event.shiftKey && event.key === 'Tab')
|
||||
) {
|
||||
let nextFocusableCellInRow: HTMLElement;
|
||||
let nextFocusableCellInRowFound = false;
|
||||
|
||||
currentRow.childNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.tabIndex > -1 &&
|
||||
parseInt(node.id, 10) > parseInt(cellId, 10) &&
|
||||
!nextFocusableCellInRowFound
|
||||
) {
|
||||
nextFocusableCellInRowFound = true;
|
||||
nextFocusableCellInRow = node;
|
||||
}
|
||||
});
|
||||
|
||||
if (nextFocusableCellInRow) {
|
||||
event.preventDefault();
|
||||
|
||||
nextFocusableCellInRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
nextFocusableCellInRow.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundCellColor = (
|
||||
row: Row<T>,
|
||||
column: DataBrowserGridColumn<T>,
|
||||
) => {
|
||||
// Grey out files not uploaded
|
||||
if (!row.values.isUploaded) {
|
||||
return 'grey.200';
|
||||
}
|
||||
|
||||
if (column.isDisabled) {
|
||||
return 'grey.100';
|
||||
}
|
||||
|
||||
return 'background.paper';
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||
{rows.length === 0 && !loading && (
|
||||
<div className="flex flex-nowrap pr-5">
|
||||
{onInsertRow ? (
|
||||
<InsertPlaceholderTableRow
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth - SELECTION_CELL_WIDTH,
|
||||
}}
|
||||
onInsertRow={onInsertRow}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className="inline-flex h-12 items-center border-b-1 border-r-1 px-2 py-1.5 text-xs"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth,
|
||||
}}
|
||||
>
|
||||
{emptyStateMessage}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map((row, index) => {
|
||||
let rowKey = index.toString();
|
||||
|
||||
if (primaryAndUniqueKeys && primaryAndUniqueKeys.length > 0) {
|
||||
rowKey = primaryAndUniqueKeys
|
||||
.map((key) => row.values[key])
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
} else {
|
||||
rowKey = `${index}-${Object.keys(row.values)
|
||||
.map((key) => String(row.values[key]))
|
||||
.join('-')}`;
|
||||
}
|
||||
|
||||
prepareRow(row);
|
||||
|
||||
const rowProps = row.getRowProps({
|
||||
style: {
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment key={rowKey.toString()}>
|
||||
<div
|
||||
{...rowProps}
|
||||
id={row.id}
|
||||
className="flex scroll-mt-10"
|
||||
role="row"
|
||||
onKeyDown={(event) => handleKeyDown(event, row)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
const column = cell.column as DataBrowserGridColumn<T>;
|
||||
const isCellDisabled =
|
||||
cell.value !== 0 &&
|
||||
!cell.value &&
|
||||
column.type !== 'boolean' &&
|
||||
column.id !== 'selection' &&
|
||||
column.isDisabled;
|
||||
|
||||
return (
|
||||
<DataGridCell
|
||||
{...cell.getCellProps({
|
||||
style: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
cell={cell}
|
||||
sx={{
|
||||
backgroundColor: getBackgroundCellColor(row, column),
|
||||
color: isCellDisabled ? 'text.secondary' : 'text.primary',
|
||||
}}
|
||||
className={twMerge(
|
||||
'h-12 font-display text-xs motion-safe:transition-colors',
|
||||
'border-b-1 border-r-1',
|
||||
'scroll-ml-8 scroll-mt-[57px]',
|
||||
column.id === 'selection' &&
|
||||
'sticky left-0 z-20 justify-center px-0',
|
||||
)}
|
||||
isEditable={!column.isDisabled && column.isEditable}
|
||||
id={cellIndex.toString()}
|
||||
key={column.id}
|
||||
>
|
||||
{cell.render('Cell')}
|
||||
</DataGridCell>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowInsertColumn && (
|
||||
<Box className="h-12 w-25 border-b-1 border-r-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onInsertRow && index === rows.length - 1 && (
|
||||
<InsertPlaceholderTableRow
|
||||
{...rowProps}
|
||||
key=""
|
||||
onInsertRow={onInsertRow}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridBody';
|
||||
export { default as DataGridBody } from './DataGridBody';
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type DataGridBooleanCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, boolean | null>;
|
||||
|
||||
export default function DataGridBooleanCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { isNullable },
|
||||
},
|
||||
}: DataGridBooleanCellProps<TData>) {
|
||||
const {
|
||||
inputRef,
|
||||
isEditing,
|
||||
focusCell,
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
isSelected,
|
||||
} = useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleMenuClick(
|
||||
event: MouseEvent<HTMLLIElement> | ReactKeyboardEvent<HTMLLIElement>,
|
||||
value: boolean | null,
|
||||
) {
|
||||
event.stopPropagation();
|
||||
await onSave(value);
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
async function handleMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// We need to restore the temporary value, because editing was cancelled
|
||||
if (event.key === 'Escape' && onTemporaryValueChange) {
|
||||
event.stopPropagation();
|
||||
|
||||
onTemporaryValueChange(optimisticValue);
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && onSave) {
|
||||
await onSave(temporaryValue);
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemporaryValueChange(value: boolean | null) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
return isSelected ? (
|
||||
<Dropdown.Root id="boolean-data-editor" className="h-full w-full">
|
||||
<Dropdown.Trigger
|
||||
id="boolean-trigger"
|
||||
className={twMerge(
|
||||
'h-full w-full border-none p-0 outline-none',
|
||||
isEditing && 'p-1.5',
|
||||
)}
|
||||
ref={inputRef}
|
||||
onClick={editCell}
|
||||
autoFocus={false}
|
||||
sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}
|
||||
>
|
||||
<ReadOnlyToggle checked={optimisticValue} />
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
disablePortal
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
PaperProps={{ className: 'w-[200px]' }}
|
||||
TransitionProps={{ onExited: focusCell }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === true}
|
||||
onKeyUp={() => handleTemporaryValueChange(true)}
|
||||
onClick={(event) => handleMenuClick(event, true)}
|
||||
>
|
||||
<ReadOnlyToggle checked />
|
||||
</Dropdown.Item>
|
||||
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === false}
|
||||
onKeyUp={() => handleTemporaryValueChange(false)}
|
||||
onClick={(event) => handleMenuClick(event, false)}
|
||||
>
|
||||
<ReadOnlyToggle checked={false} />
|
||||
</Dropdown.Item>
|
||||
|
||||
{isNullable && (
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === null}
|
||||
onKeyUp={() => handleTemporaryValueChange(null)}
|
||||
onClick={(event) => handleMenuClick(event, null)}
|
||||
>
|
||||
<ReadOnlyToggle checked={null} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
) : (
|
||||
<ReadOnlyToggle checked={optimisticValue} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridBooleanCell';
|
||||
export { default as DataGridBooleanCell } from './DataGridBooleanCell';
|
||||
@@ -0,0 +1,381 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Tooltip, useTooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type {
|
||||
ColumnType,
|
||||
DataBrowserGridCell,
|
||||
DataBrowserGridCellProps,
|
||||
} from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type {
|
||||
FocusEvent,
|
||||
JSXElementConstructor,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
ReactPortal,
|
||||
} from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import DataGridCellProvider from './DataGridCellProvider';
|
||||
import useDataGridCell from './useDataGridCell';
|
||||
|
||||
export interface CommonDataGridCellProps<TData extends object, TValue = any>
|
||||
extends DataBrowserGridCellProps<TData, TValue> {
|
||||
/**
|
||||
* Function that is called when the cell is saved.
|
||||
*/
|
||||
onSave?: (value: TValue, options?: { reset: boolean }) => Promise<void>;
|
||||
/**
|
||||
* Optimistic value for the cell.
|
||||
*/
|
||||
optimisticValue?: TValue;
|
||||
/**
|
||||
* Function to be called when the optimistic value should be changed.
|
||||
*/
|
||||
onOptimisticValueChange?: (value: TValue) => void;
|
||||
/**
|
||||
* Temporary value for the cell. This is used for storing the current input
|
||||
* value, that should be later saved as an optimistic value before saving the
|
||||
* data.
|
||||
*/
|
||||
temporaryValue?: TValue;
|
||||
/**
|
||||
* Function to be called when the temporary value should be changed.
|
||||
*/
|
||||
onTemporaryValueChange?: (value: TValue) => void;
|
||||
}
|
||||
|
||||
export interface DataGridCellProps<TData extends object, TValue = unknown>
|
||||
extends BoxProps {
|
||||
/**
|
||||
* Current cell's props.
|
||||
*/
|
||||
cell: DataBrowserGridCell<TData, TValue>;
|
||||
/**
|
||||
* Determines whether the cell is editable.
|
||||
*/
|
||||
isEditable?: boolean;
|
||||
/**
|
||||
* Determines the column's type.
|
||||
*/
|
||||
columnType?: ColumnType;
|
||||
}
|
||||
|
||||
function DataGridCellContent<TData extends object = {}, TValue = unknown>({
|
||||
isEditable,
|
||||
children,
|
||||
className,
|
||||
cell: {
|
||||
value: originalValue,
|
||||
column: { onCellEdit, id, isNullable, isPrimary, type },
|
||||
row,
|
||||
},
|
||||
...props
|
||||
}: DataGridCellProps<TData, TValue>) {
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const {
|
||||
title: tooltipTitle,
|
||||
open: tooltipOpen,
|
||||
openTooltip,
|
||||
closeTooltip,
|
||||
resetTooltipTitle,
|
||||
} = useTooltip();
|
||||
|
||||
const [optimisticValue, setOptimisticValue] = useState<TValue>(originalValue);
|
||||
const [temporaryValue, setTemporaryValue] = useState<TValue>(originalValue);
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticValue(originalValue);
|
||||
setTemporaryValue(originalValue);
|
||||
}, [originalValue]);
|
||||
|
||||
const {
|
||||
cellRef,
|
||||
inputRef,
|
||||
focusCell,
|
||||
focusInput,
|
||||
blurInput,
|
||||
clickInput,
|
||||
isEditing,
|
||||
isSelected,
|
||||
selectCell,
|
||||
deselectCell,
|
||||
cancelEditCell,
|
||||
editCell,
|
||||
focusPrevCell,
|
||||
focusNextCell,
|
||||
} = useDataGridCell();
|
||||
|
||||
function activateInput() {
|
||||
if (isPrimary) {
|
||||
openTooltip("Primary keys can't be edited.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editCell();
|
||||
|
||||
if (type === 'boolean') {
|
||||
clickInput();
|
||||
} else {
|
||||
focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!isEditable || isEditing || isPrimary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail === 2 && type !== 'boolean') {
|
||||
editCell();
|
||||
await focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectCell();
|
||||
}
|
||||
|
||||
async function handleSave(
|
||||
value: TValue,
|
||||
options: { reset: boolean } = { reset: false },
|
||||
) {
|
||||
if (!onCellEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedValue =
|
||||
value !== null && typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
|
||||
const normalizedOptimisticValue =
|
||||
optimisticValue !== null && typeof optimisticValue === 'object'
|
||||
? JSON.stringify(optimisticValue)
|
||||
: String(optimisticValue);
|
||||
|
||||
// We are making sure that optimistic value is not equal to the current
|
||||
// value. If it is, we are not going to save the value.
|
||||
if (
|
||||
normalizedValue.replace(/\n/gi, '\\n') ===
|
||||
normalizedOptimisticValue.replace(/\n/gi, '\\n') &&
|
||||
!options.reset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In case of an error, we need to reset optimistic value
|
||||
const latestOptimisticValue = optimisticValue;
|
||||
|
||||
setOptimisticValue(value);
|
||||
|
||||
try {
|
||||
const data = await onCellEdit({
|
||||
row,
|
||||
columnsToUpdate: {
|
||||
[id]: {
|
||||
value: !options.reset ? value : undefined,
|
||||
reset: options.reset,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Syncing optimistic value with server-side value
|
||||
setTemporaryValue(data.original[id.toString()]);
|
||||
setOptimisticValue(data.original[id.toString()]);
|
||||
} catch (error) {
|
||||
triggerToast(`Error: ${error.message || 'Unknown error occurred.'}`);
|
||||
|
||||
// Resetting values
|
||||
setTemporaryValue(latestOptimisticValue);
|
||||
setOptimisticValue(latestOptimisticValue);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlur(event: FocusEvent<HTMLDivElement>) {
|
||||
// We are deselecting cell only if focus target is not a descendant of it.
|
||||
if (!isEditable || event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSave(temporaryValue);
|
||||
closeTooltip();
|
||||
deselectCell();
|
||||
}
|
||||
|
||||
function resetCell() {
|
||||
if (isPrimary) {
|
||||
openTooltip('Primary keys are non-nullable.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNullable) {
|
||||
openTooltip(
|
||||
<span>
|
||||
<strong>{id}</strong>
|
||||
is non-nullable.
|
||||
</span>,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openAlertDialog({
|
||||
title: 'Set value to null',
|
||||
payload: (
|
||||
<p>
|
||||
Are you sure you want to set this cell to <strong>null</strong>?
|
||||
</p>
|
||||
),
|
||||
props: {
|
||||
primaryButtonText: 'Set to null',
|
||||
primaryButtonColor: 'error',
|
||||
onPrimaryAction: async () => {
|
||||
await handleSave(null, { reset: true });
|
||||
focusCell();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
closeTooltip();
|
||||
}
|
||||
|
||||
// Resetting temporary value and focusing cell on Escape when input field is
|
||||
// focused
|
||||
if (event.key === 'Escape' && event.target === inputRef.current) {
|
||||
setTemporaryValue(optimisticValue);
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
// Activating input field on Enter
|
||||
if (event.key === 'Enter' && event.target === cellRef.current) {
|
||||
activateInput();
|
||||
}
|
||||
|
||||
// Focusing next cell on Tab
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
const nextCellAvailable = focusNextCell();
|
||||
|
||||
if (!nextCellAvailable) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await blurInput();
|
||||
await focusCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Focusing previous cell on Shift-Tab
|
||||
if (event.key === 'Tab' && event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
const prevCellAvailable = focusPrevCell();
|
||||
|
||||
if (!prevCellAvailable) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await blurInput();
|
||||
await focusCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Initiating cell reset when cell is focused
|
||||
if (event.key === 'Backspace' && event.target === cellRef.current) {
|
||||
resetCell();
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
ref={cellRef}
|
||||
className={twMerge(
|
||||
'relative grid h-full w-full cursor-default grid-flow-col items-center gap-1',
|
||||
isEditable &&
|
||||
'focus-within:outline-none focus-within:ring-0 focus:ring-0',
|
||||
isSelected && 'shadow-outline',
|
||||
isEditing ? 'p-0.5 shadow-outline-dark' : 'px-2 py-1.5',
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={isEditable ? 0 : undefined}
|
||||
onClick={handleClick}
|
||||
role="textbox"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
>
|
||||
{Children.map(
|
||||
children,
|
||||
(
|
||||
child:
|
||||
| ReactNode
|
||||
| ReactPortal
|
||||
| ReactElement<unknown, string | JSXElementConstructor<any>>,
|
||||
) => {
|
||||
if (!isValidElement(child)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cloneElement(child, {
|
||||
...child.props,
|
||||
onSave: handleSave,
|
||||
optimisticValue,
|
||||
onOptimisticValueChange: setOptimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange: setTemporaryValue,
|
||||
});
|
||||
},
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isEditable) {
|
||||
return (
|
||||
<Tooltip
|
||||
disableHoverListener
|
||||
disableFocusListener
|
||||
open={tooltipOpen}
|
||||
title={tooltipTitle || ''}
|
||||
TransitionProps={{ onExited: resetTooltipTitle }}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function DataGridCell<TData extends object, TValue = unknown>(
|
||||
props: DataGridCellProps<TData, TValue>,
|
||||
) {
|
||||
return (
|
||||
<DataGridCellProvider>
|
||||
<DataGridCellContent {...props} />
|
||||
</DataGridCellProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import type { MutableRefObject, PropsWithChildren } from 'react';
|
||||
import { createContext, useCallback, useMemo, useReducer, useRef } from 'react';
|
||||
|
||||
export interface DataGridCellContextProps<T extends HTMLElement> {
|
||||
/**
|
||||
* This `ref` should be attached to the cell element.
|
||||
*/
|
||||
cellRef: MutableRefObject<HTMLDivElement>;
|
||||
/**
|
||||
* This `ref` should be attached to the input element inside the data grid cell.
|
||||
*/
|
||||
inputRef: MutableRefObject<T>;
|
||||
/**
|
||||
* Determines whether or not the cell is currently being edited.
|
||||
*/
|
||||
isEditing: boolean;
|
||||
/**
|
||||
* Determines whether or not the cell is currently selected.
|
||||
*/
|
||||
isSelected: boolean;
|
||||
/**
|
||||
* Function to be called to start editing.
|
||||
*/
|
||||
editCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to cancel editing.
|
||||
*/
|
||||
cancelEditCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to select the cell, but not start editing.
|
||||
*/
|
||||
selectCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to deselect cell and cancel editing.
|
||||
*/
|
||||
deselectCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to focus cell.
|
||||
*/
|
||||
focusCell: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to blur cell.
|
||||
*/
|
||||
blurCell: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programatically focus the input in the cell.
|
||||
*/
|
||||
focusInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programatically blur the input in the cell.
|
||||
*/
|
||||
blurInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programmatically click the input in the cell.
|
||||
*/
|
||||
clickInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to navigate to next cell if available.
|
||||
*
|
||||
* @returns `true` if there is a next cell to focus, `false` otherwise.
|
||||
*/
|
||||
focusNextCell: () => boolean;
|
||||
/**
|
||||
* Function to be called to navigate to previous cell if available.
|
||||
*
|
||||
* @returns `true` if there is a previous cell to focus, `false` otherwise.
|
||||
*/
|
||||
focusPrevCell: () => boolean;
|
||||
}
|
||||
|
||||
export const DataGridCellContext =
|
||||
createContext<DataGridCellContextProps<any>>(null);
|
||||
|
||||
interface EditAndSelectState {
|
||||
isEditing: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
type EditAndSelectAction =
|
||||
| { type: 'EDIT' }
|
||||
| { type: 'CANCEL_EDIT' }
|
||||
| { type: 'SELECT' }
|
||||
| { type: 'DESELECT' };
|
||||
|
||||
function editAndSelectCellReducer(
|
||||
state: EditAndSelectState,
|
||||
action: EditAndSelectAction,
|
||||
): EditAndSelectState {
|
||||
switch (action.type) {
|
||||
case 'EDIT':
|
||||
return { ...state, isEditing: true, isSelected: true };
|
||||
case 'CANCEL_EDIT':
|
||||
return { ...state, isEditing: false };
|
||||
case 'SELECT':
|
||||
return { ...state, isSelected: true };
|
||||
case 'DESELECT':
|
||||
return { ...state, isEditing: false, isSelected: false };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataGridCellProvider<TInput extends HTMLElement>({
|
||||
children,
|
||||
}: PropsWithChildren<unknown>) {
|
||||
const cellRef = useRef<HTMLDivElement>();
|
||||
const inputRef = useRef<TInput>();
|
||||
const [{ isEditing, isSelected }, dispatch] = useReducer(
|
||||
editAndSelectCellReducer,
|
||||
{
|
||||
isEditing: false,
|
||||
isSelected: false,
|
||||
},
|
||||
);
|
||||
|
||||
function focusCell() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
cellRef.current?.focus();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deselectCell() {
|
||||
dispatch({ type: 'DESELECT' });
|
||||
}
|
||||
|
||||
const focusPrevCell = useCallback(() => {
|
||||
const prevCellAvailable =
|
||||
cellRef.current.previousElementSibling instanceof HTMLElement &&
|
||||
cellRef.current.previousElementSibling.tabIndex > -1;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (prevCellAvailable) {
|
||||
(cellRef.current.previousElementSibling as HTMLElement).focus();
|
||||
deselectCell();
|
||||
}
|
||||
});
|
||||
|
||||
return prevCellAvailable;
|
||||
}, []);
|
||||
|
||||
const focusNextCell = useCallback(() => {
|
||||
const nextCellAvailable =
|
||||
cellRef.current.nextElementSibling instanceof HTMLElement &&
|
||||
cellRef.current.nextElementSibling.tabIndex > -1;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (nextCellAvailable) {
|
||||
(cellRef.current.nextElementSibling as HTMLElement).focus();
|
||||
deselectCell();
|
||||
}
|
||||
});
|
||||
|
||||
return nextCellAvailable;
|
||||
}, []);
|
||||
|
||||
function blurCell() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
cellRef.current?.blur();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function blurInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.blur();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clickInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.click();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editCell() {
|
||||
dispatch({ type: 'EDIT' });
|
||||
}
|
||||
|
||||
function cancelEditCell() {
|
||||
dispatch({ type: 'CANCEL_EDIT' });
|
||||
}
|
||||
|
||||
function selectCell() {
|
||||
dispatch({ type: 'SELECT' });
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
focusCell,
|
||||
blurCell,
|
||||
focusInput,
|
||||
blurInput,
|
||||
clickInput,
|
||||
isEditing,
|
||||
isSelected,
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
selectCell,
|
||||
deselectCell,
|
||||
cellRef,
|
||||
inputRef,
|
||||
focusPrevCell,
|
||||
focusNextCell,
|
||||
}),
|
||||
[focusNextCell, focusPrevCell, isEditing, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGridCellContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridCellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from './DataGridCell';
|
||||
export { default as DataGridCell } from './DataGridCell';
|
||||
export * from './DataGridCellProvider';
|
||||
export { default as DataGridCellProvider } from './DataGridCellProvider';
|
||||
export { default as useDataGridCell } from './useDataGridCell';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react';
|
||||
import type { DataGridCellContextProps } from './DataGridCellProvider';
|
||||
import { DataGridCellContext } from './DataGridCellProvider';
|
||||
|
||||
export default function useDataGridCell<TInput extends HTMLElement>() {
|
||||
const context =
|
||||
useContext<DataGridCellContextProps<TInput>>(DataGridCellContext);
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import { createContext } from 'react';
|
||||
|
||||
const DataGridConfigContext = createContext<Partial<UseDataGridReturn>>(null);
|
||||
|
||||
export default DataGridConfigContext;
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import DataGridConfigContext from './DataGridConfigContext';
|
||||
|
||||
export default function DataGridConfigProvider<T extends object = {}>({
|
||||
children,
|
||||
...value
|
||||
}: PropsWithChildren<UseDataGridReturn<T>>) {
|
||||
return (
|
||||
<DataGridConfigContext.Provider
|
||||
value={value as unknown as UseDataGridReturn<{}>}
|
||||
>
|
||||
{children}
|
||||
</DataGridConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as DataGridConfigContext } from './DataGridConfigContext';
|
||||
export { default as DataGridConfigProvider } from './DataGridConfigProvider';
|
||||
export { default as useDataGridConfig } from './useDataGridConfig';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import { useContext } from 'react';
|
||||
import DataGridConfigContext from './DataGridConfigContext';
|
||||
|
||||
export default function useDataGridConfig<T extends object = {}>() {
|
||||
const context = useContext(DataGridConfigContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useDataGridConfig must be used within a DataGridConfigContext`,
|
||||
);
|
||||
}
|
||||
|
||||
return context as unknown as UseDataGridReturn<T>;
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import type { TextProps } from '@/components/ui/v2/Text';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DataGridDateCellProps<TData extends object>
|
||||
extends CommonDataGridCellProps<TData, string> {
|
||||
/**
|
||||
* Props to be passed to date display.
|
||||
*/
|
||||
dateProps?: TextProps;
|
||||
/**
|
||||
* Props to be passed to time display.
|
||||
*/
|
||||
timeProps?: TextProps;
|
||||
}
|
||||
|
||||
export default function DataGridDateCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { specificType },
|
||||
},
|
||||
dateProps,
|
||||
timeProps,
|
||||
className,
|
||||
}: DataGridDateCellProps<TData>) {
|
||||
const { className: dateClassName, ...restDateProps } = dateProps || {};
|
||||
const { className: timeClassName, ...restTimeProps } = timeProps || {};
|
||||
|
||||
// Note: No date (year-month-day) is saved for time / timetz columns, so we
|
||||
// need to add it manually.
|
||||
const date =
|
||||
optimisticValue && specificType !== 'interval'
|
||||
? new Date(
|
||||
specificType === 'time' || specificType === 'timetz'
|
||||
? `1970-01-01 ${optimisticValue}`
|
||||
: optimisticValue,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const { year, month, day, hour, minute, second } = getDateComponents(date, {
|
||||
adjustTimezone: ['date', 'timetz', 'timestamptz'].includes(specificType),
|
||||
});
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
await onSave(temporaryValue || '');
|
||||
}
|
||||
}
|
||||
|
||||
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 (event.target instanceof HTMLInputElement && onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
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) {
|
||||
return (
|
||||
<Text className="truncate text-xs" color="secondary">
|
||||
null
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (specificType === 'interval') {
|
||||
return <Text className="truncate text-xs">{optimisticValue}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={twMerge('grid grid-flow-row', className)}>
|
||||
{specificType !== 'time' && specificType !== 'timetz' && (
|
||||
<Text
|
||||
className={twMerge('truncate text-xs', dateClassName)}
|
||||
{...restDateProps}
|
||||
>
|
||||
{[year, month, day].filter(Boolean).join('-')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{specificType !== 'date' && (
|
||||
<Text
|
||||
className={twMerge('truncate text-xs', timeClassName)}
|
||||
color={
|
||||
specificType === 'time' || specificType === 'timetz'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
}
|
||||
{...restTimeProps}
|
||||
>
|
||||
{[hour, minute, second].filter(Boolean).join(':')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridDateCell';
|
||||
export { default as DataGridDateCell } from './DataGridDateCell';
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import clsx from 'clsx';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
|
||||
export type DataGridFrameProps = DetailedHTMLProps<
|
||||
HTMLProps<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
|
||||
export default function DataGridFrame<T extends object>({
|
||||
style,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataGridFrameProps) {
|
||||
const { getTableProps } = useDataGridConfig<T>();
|
||||
const { style: reactTableStyle, ...restTableProps } = getTableProps();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...restTableProps}
|
||||
{...props}
|
||||
className={clsx('min-w-min', className)}
|
||||
style={{ ...reactTableStyle, minWidth: undefined, ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridFrame';
|
||||
export { default as DataGridFrame } from './DataGridFrame';
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { ArrowDownIcon } from '@/components/ui/v2/icons/ArrowDownIcon';
|
||||
import { ArrowUpIcon } from '@/components/ui/v2/icons/ArrowUpIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface HeaderActionProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLElement>, HTMLElement> {}
|
||||
|
||||
export interface DataGridHeaderProps<T extends object>
|
||||
extends Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>,
|
||||
Pick<
|
||||
DataGridProps<T>,
|
||||
'onRemoveColumn' | 'onEditColumn' | 'onInsertColumn'
|
||||
> {
|
||||
/**
|
||||
* Props to be passed to component slots.
|
||||
*/
|
||||
componentsProps?: {
|
||||
/**
|
||||
* Props to be passed to the `Edit Column` header action item.
|
||||
*/
|
||||
editActionProps?: HeaderActionProps;
|
||||
/**
|
||||
* Props to be passed to the `Delete Column` header action item.
|
||||
*/
|
||||
deleteActionProps?: HeaderActionProps;
|
||||
/**
|
||||
* Props to be passed to the `Delete Column` header action item.
|
||||
*/
|
||||
insertActionProps?: HeaderActionProps;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Get rid of Data Browser related code from here. This component should
|
||||
// be generic and not depend on Data Browser related data types and logic.
|
||||
export default function DataGridHeader<T extends object>({
|
||||
className,
|
||||
onRemoveColumn,
|
||||
onEditColumn,
|
||||
onInsertColumn,
|
||||
componentsProps,
|
||||
...props
|
||||
}: DataGridHeaderProps<T>) {
|
||||
const { flatHeaders, allowSort, allowResize } = useDataGridConfig<T>();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'sticky top-0 z-30 inline-flex w-full items-center pr-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{flatHeaders.map((column: DataBrowserGridColumn<T>) => {
|
||||
const headerProps = column.getHeaderProps({
|
||||
style: { display: 'inline-grid' },
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown.Root
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
column.isDisabled
|
||||
? theme.palette.background.default
|
||||
: theme.palette.background.paper,
|
||||
color: 'text.primary',
|
||||
borderColor: 'grey.300',
|
||||
}}
|
||||
className={twMerge(
|
||||
'group relative inline-flex self-stretch overflow-hidden font-display text-xs font-bold focus:outline-none focus-visible:outline-none',
|
||||
'border-b-1 border-r-1',
|
||||
column.id === 'selection' && 'sticky left-0 max-w-2',
|
||||
)}
|
||||
style={{
|
||||
...headerProps.style,
|
||||
maxWidth:
|
||||
column.id === 'selection' ? 32 : headerProps.style?.maxWidth,
|
||||
width:
|
||||
column.id === 'selection' ? '100%' : headerProps.style?.width,
|
||||
zIndex:
|
||||
column.id === 'selection' ? 10 : headerProps.style?.zIndex,
|
||||
position: null,
|
||||
}}
|
||||
key={column.id}
|
||||
>
|
||||
{column.id === 'selection' ? (
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
</span>
|
||||
) : (
|
||||
<Dropdown.Trigger
|
||||
className={twMerge(
|
||||
'focus:outline-none motion-safe:transition-colors',
|
||||
)}
|
||||
disabled={
|
||||
column.isDisabled || (column.disableSortBy && !onRemoveColumn)
|
||||
}
|
||||
hideChevron
|
||||
>
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
|
||||
{allowSort && (
|
||||
<Box component="span" sx={{ color: 'text.primary' }}>
|
||||
{column.isSorted && !column.isSortedDesc && (
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
)}
|
||||
|
||||
{column.isSorted && column.isSortedDesc && (
|
||||
<ArrowDownIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{allowResize && !column.disableResizing && (
|
||||
<span
|
||||
{...column.getResizerProps({
|
||||
onClick: (event: Event) => event.stopPropagation(),
|
||||
})}
|
||||
className="absolute -right-0.5 bottom-0 top-0 z-10 h-full w-1.5 group-hover:bg-slate-900 group-hover:bg-opacity-20 group-active:bg-slate-900 group-active:bg-opacity-20 motion-safe:transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Dropdown.Trigger>
|
||||
)}
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52 mt-1' }}
|
||||
className="p-0"
|
||||
>
|
||||
{onEditColumn && (
|
||||
<Dropdown.Item
|
||||
onClick={() => onEditColumn(column)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
disabled={componentsProps?.editActionProps?.disabled}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Column</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{onEditColumn && <Divider component="li" sx={{ margin: 0 }} />}
|
||||
|
||||
{!column.disableSortBy && (
|
||||
<Dropdown.Item
|
||||
onClick={() => column.toggleSortBy(false)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<ArrowUpIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Sort Ascending</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{!column.disableSortBy && (
|
||||
<Dropdown.Item
|
||||
onClick={() => column.toggleSortBy(true)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<ArrowDownIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Sort Descending</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{onRemoveColumn && !column.isPrimary && (
|
||||
<Divider component="li" className="my-1" />
|
||||
)}
|
||||
|
||||
{onRemoveColumn && !column.isPrimary && (
|
||||
<Dropdown.Item
|
||||
onClick={() => onRemoveColumn(column)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
disabled={componentsProps?.deleteActionProps?.disabled}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" sx={{ color: 'error.main' }} />
|
||||
|
||||
<span>Delete Column</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
})}
|
||||
|
||||
{onInsertColumn && (
|
||||
<Box className="group relative inline-flex w-25 self-stretch overflow-hidden border-b-1 border-r-1 font-display text-xs font-bold focus:outline-none focus-visible:outline-none">
|
||||
<Button
|
||||
onClick={onInsertColumn}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full rounded-none text-xs hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
aria-label="Insert New Column"
|
||||
disabled={componentsProps?.insertActionProps?.disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" sx={{ color: 'text.disabled' }} />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridHeader';
|
||||
export { default as DataGridHeader } from './DataGridHeader';
|
||||
@@ -0,0 +1,110 @@
|
||||
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 DataGridNumericCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, number>;
|
||||
|
||||
export default function DataGridNumericCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
}: DataGridNumericCellProps<TData>) {
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
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) {
|
||||
if (event.target.value) {
|
||||
onTemporaryValueChange(parseInt(event.target.value, 10));
|
||||
} else {
|
||||
onTemporaryValueChange(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
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 './DataGridNumericCell';
|
||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import type { IconButtonProps } from '@/components/ui/v2/IconButton';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ChevronLeftIcon } from '@/components/ui/v2/icons/ChevronLeftIcon';
|
||||
import { ChevronRightIcon } from '@/components/ui/v2/icons/ChevronRightIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface DataGridPaginationProps extends BoxProps {
|
||||
/**
|
||||
* Number of pages.
|
||||
*/
|
||||
totalPages: number;
|
||||
/**
|
||||
* Current page.
|
||||
*/
|
||||
currentPage: number;
|
||||
/**
|
||||
* Function to be called when navigating to the previous page.
|
||||
*/
|
||||
onOpenPrevPage: VoidFunction;
|
||||
/**
|
||||
* Function to be called when navigating to the next page.
|
||||
*/
|
||||
onOpenNextPage: VoidFunction;
|
||||
/**
|
||||
* Props to be passed to the next button component.
|
||||
*/
|
||||
nextButtonProps?: IconButtonProps;
|
||||
/**
|
||||
* Props to be passed to the previous button component.
|
||||
*/
|
||||
prevButtonProps?: IconButtonProps;
|
||||
}
|
||||
|
||||
export default function DataGridPagination({
|
||||
className,
|
||||
totalPages,
|
||||
currentPage,
|
||||
onOpenPrevPage,
|
||||
onOpenNextPage,
|
||||
nextButtonProps,
|
||||
prevButtonProps,
|
||||
...props
|
||||
}: DataGridPaginationProps) {
|
||||
return (
|
||||
<Box
|
||||
className={clsx(
|
||||
'grid grid-flow-col items-center justify-around rounded-md border-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={onOpenPrevPage}
|
||||
aria-label="Previous page"
|
||||
{...prevButtonProps}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
'mx-1 inline-block font-display font-medium',
|
||||
currentPage > 99 ? 'text-xs' : 'text-sm+',
|
||||
)}
|
||||
>
|
||||
{currentPage}
|
||||
<Text component="span" className="mx-1 inline-block" color="disabled">
|
||||
/
|
||||
</Text>
|
||||
{totalPages}
|
||||
</span>
|
||||
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={onOpenNextPage}
|
||||
aria-label="Next page"
|
||||
{...nextButtonProps}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridPagination';
|
||||
export { default as DataGridPagination } from './DataGridPagination';
|
||||
@@ -0,0 +1,410 @@
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { AudioPreviewIcon } from '@/components/ui/v2/icons/AudioPreviewIcon';
|
||||
import { FilePreviewIcon } from '@/components/ui/v2/icons/FilePreviewIcon';
|
||||
import { PDFPreviewIcon } from '@/components/ui/v2/icons/PDFPreviewIcon';
|
||||
import { VideoPreviewIcon } from '@/components/ui/v2/icons/VideoPreviewIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import type { CellProps } from 'react-table';
|
||||
|
||||
export type PreviewProps = {
|
||||
fetchBlob: (
|
||||
init?: RequestInit,
|
||||
size?: { width?: number; height?: number },
|
||||
) => Promise<Blob | null>;
|
||||
mimeType?: string;
|
||||
alt?: string;
|
||||
blob?: Blob;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type DataGridPreviewCellProps<TData extends object> = CellProps<
|
||||
TData,
|
||||
PreviewProps
|
||||
> & {
|
||||
/**
|
||||
* Preview to use when the file is not an image or blob can't be fetched
|
||||
* properly.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
fallbackPreview?: ReactNode;
|
||||
};
|
||||
|
||||
function useBlob({
|
||||
fetchBlob,
|
||||
blob,
|
||||
mimeType,
|
||||
}: Pick<PreviewProps, 'fetchBlob' | 'blob' | 'mimeType'>) {
|
||||
const [objectUrl, setObjectUrl] = useState<string>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
// This side-effect fetches the blob of the file from the server and sets the
|
||||
// relevant `objectUrl` state. Abort controller is reponsible for cancelling
|
||||
// the fetch if the component is unmounted.
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function generateOptimizedObjectUrl() {
|
||||
// todo: it could be more declarative if this function was called with the
|
||||
// actual preview URL here, not pre-generated in useFiles
|
||||
const fetchedBlob = await fetchBlob(
|
||||
{ signal: abortController.signal },
|
||||
mimeType !== 'image/svg+xml' && { width: 80, height: 40 },
|
||||
);
|
||||
|
||||
if (fetchedBlob) {
|
||||
return URL.createObjectURL(fetchedBlob);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function generateObjectUrl() {
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
|
||||
if (objectUrl || (mimeType && !mimeType?.startsWith('image'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
setObjectUrl(URL.createObjectURL(blob));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const generatedObjectUrl = await generateOptimizedObjectUrl();
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setObjectUrl(generatedObjectUrl);
|
||||
}
|
||||
} catch (generateError) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(generateError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
generateObjectUrl();
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [blob, fetchBlob, objectUrl, mimeType]);
|
||||
|
||||
return { objectUrl, error, loading };
|
||||
}
|
||||
|
||||
const previewableImages = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const previewableVideos = [
|
||||
'video/mp4',
|
||||
'video/x-m4v',
|
||||
'video/3gpp',
|
||||
'video/3gpp2',
|
||||
];
|
||||
|
||||
const previewableFileTypes = [
|
||||
...previewableImages,
|
||||
...previewableVideos,
|
||||
'audio/',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
function previewReducer(
|
||||
state: { loading: boolean; error?: Error; data?: string },
|
||||
action:
|
||||
| { type: 'PREVIEW_LOADING' }
|
||||
| { type: 'CLEAR_PREVIEW' }
|
||||
| { type: 'PREVIEW_FETCHED'; payload: string }
|
||||
| { type: 'PREVIEW_ERROR'; payload: Error },
|
||||
): { loading: boolean; error?: Error; data?: string } {
|
||||
switch (action.type) {
|
||||
case 'PREVIEW_LOADING':
|
||||
return { ...state, loading: true, error: undefined, data: undefined };
|
||||
case 'PREVIEW_FETCHED':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
data: action.payload,
|
||||
};
|
||||
case 'PREVIEW_ERROR':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
data: undefined,
|
||||
};
|
||||
case 'CLEAR_PREVIEW':
|
||||
return { ...state, loading: false, error: undefined, data: undefined };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataGridPreviewCell<TData extends object>({
|
||||
value: { fetchBlob, id, mimeType, alt, blob },
|
||||
fallbackPreview = null,
|
||||
}: DataGridPreviewCellProps<TData>) {
|
||||
const { project } = useProject({ target: 'user-project' });
|
||||
const appClient = useAppClient();
|
||||
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const [
|
||||
{ loading: previewLoading, error: previewError, data: previewUrl },
|
||||
dispatch,
|
||||
] = useReducer(previewReducer, {
|
||||
loading: false,
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const isPreviewable = previewableFileTypes.some(
|
||||
(type) => mimeType?.startsWith(type) || mimeType === type,
|
||||
);
|
||||
|
||||
const isVideo = mimeType?.startsWith('video');
|
||||
const isAudio = mimeType?.startsWith('audio');
|
||||
const isImage = mimeType?.startsWith('image');
|
||||
const isJson = mimeType === 'application/json';
|
||||
|
||||
async function handleOpenPreview() {
|
||||
if (!mimeType) {
|
||||
dispatch({
|
||||
type: 'PREVIEW_ERROR',
|
||||
payload: new Error('mimeType is not defined.'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreviewable) {
|
||||
setShowModal(true);
|
||||
dispatch({ type: 'PREVIEW_LOADING' });
|
||||
}
|
||||
|
||||
const { presignedUrl } = await appClient.storage
|
||||
.setAdminSecret(project?.config?.hasura.adminSecret)
|
||||
.getPresignedUrl({ fileId: id });
|
||||
|
||||
if (!presignedUrl) {
|
||||
dispatch({
|
||||
type: 'PREVIEW_ERROR',
|
||||
payload: new Error('Presigned URL could not be fetched.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPreviewable) {
|
||||
window.open(presignedUrl.url, '_blank', 'noopener noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'PREVIEW_FETCHED', payload: presignedUrl.url });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={500} className="mx-auto" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
className="grid w-full grid-flow-col items-center justify-center gap-1 text-center"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<FilePreviewIcon error /> Error
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
wrapperClassName="items-center"
|
||||
showModal={showModal}
|
||||
close={() => setShowModal(false)}
|
||||
afterLeave={() => dispatch({ type: 'CLEAR_PREVIEW' })}
|
||||
className={clsx(
|
||||
previewableImages.includes(mimeType) || isVideo || isAudio
|
||||
? 'mx-12 flex h-screen items-center justify-center'
|
||||
: 'mt-4 inline-block h-near-screen w-full px-12',
|
||||
)}
|
||||
>
|
||||
<Box
|
||||
className={clsx(
|
||||
!isJson && 'bg-checker-pattern',
|
||||
'relative mx-auto flex overflow-hidden rounded-md',
|
||||
)}
|
||||
sx={{
|
||||
backgroundColor: isJson && 'background.default',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{!previewLoading && (
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="absolute right-2 top-2 z-50 p-2"
|
||||
sx={{
|
||||
[`&:hover, &:active, &:focus`]: {
|
||||
backgroundColor: (theme) => {
|
||||
if (isAudio || isVideo || isJson) {
|
||||
return 'common.black';
|
||||
}
|
||||
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'grey.800'
|
||||
: 'grey.200';
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<XIcon
|
||||
className="h-5 w-5"
|
||||
sx={{
|
||||
color: (theme) => {
|
||||
if (isAudio || isVideo || isJson) {
|
||||
return 'common.white';
|
||||
}
|
||||
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'grey.100'
|
||||
: 'grey.700';
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{previewLoading && !previewUrl && (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
className="mx-auto"
|
||||
label="Loading preview..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewError && (
|
||||
<Box
|
||||
className="px-6 py-3.5 pr-12 text-start font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<p>Error: Preview can't be loaded.</p>
|
||||
|
||||
<p>{previewError.message}</p>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{previewUrl && isImage && (
|
||||
<picture className="h-auto max-h-near-screen min-h-38 min-w-38">
|
||||
<source srcSet={previewUrl} type={mimeType} />
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={alt}
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
|
||||
{previewUrl && isVideo && (
|
||||
<video
|
||||
autoPlay
|
||||
controls
|
||||
className="h-auto max-h-near-screen w-full bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
<source src={previewUrl} type={mimeType} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
|
||||
{previewUrl && isAudio && (
|
||||
<audio autoPlay controls className="h-28 bg-black">
|
||||
<track kind="captions" />
|
||||
<source src={previewUrl} type={mimeType} />
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
)}
|
||||
|
||||
{!previewLoading &&
|
||||
previewUrl &&
|
||||
!previewableImages.includes(mimeType) &&
|
||||
!isVideo &&
|
||||
!isAudio && (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="h-near-screen w-full"
|
||||
title="File preview"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<div className="flex h-full w-full justify-center">
|
||||
{previewableImages.includes(mimeType) && objectUrl && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={alt}
|
||||
onClick={handleOpenPreview}
|
||||
className="mx-auto h-full"
|
||||
>
|
||||
<picture className="h-full w-20">
|
||||
<source srcSet={objectUrl} type={mimeType} />
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={alt}
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
</picture>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!previewableImages.includes(mimeType) || !objectUrl) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPreview}
|
||||
aria-label={alt}
|
||||
className="grid h-full w-full items-center justify-center self-center"
|
||||
>
|
||||
{isVideo && <VideoPreviewIcon className="h-5 w-5" />}
|
||||
|
||||
{isAudio && <AudioPreviewIcon className="h-5 w-5" />}
|
||||
|
||||
{mimeType === 'application/pdf' && (
|
||||
<PDFPreviewIcon className="h-5 w-5" />
|
||||
)}
|
||||
|
||||
{!isVideo &&
|
||||
!isAudio &&
|
||||
mimeType !== 'application/pdf' &&
|
||||
fallbackPreview}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridPreviewCell';
|
||||
export { default as DataGridPreviewCell } from './DataGridPreviewCell';
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { copy } from '@/utils/copy';
|
||||
import type { ChangeEvent, KeyboardEvent, Ref } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type DataGridTextCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, string>;
|
||||
|
||||
export default function DataGridTextCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { isCopiable, specificType },
|
||||
},
|
||||
}: DataGridTextCellProps<TData>) {
|
||||
const isMultiline =
|
||||
specificType === 'text' ||
|
||||
specificType === 'bpchar' ||
|
||||
specificType === 'varchar' ||
|
||||
specificType === 'json' ||
|
||||
specificType === 'jsonb';
|
||||
|
||||
const normalizedOptimisticValue =
|
||||
optimisticValue !== null && typeof optimisticValue === 'object'
|
||||
? optimisticValue
|
||||
: (String(optimisticValue) || '').replace(/(\\n)+/gi, ' ');
|
||||
|
||||
const normalizedTemporaryValue =
|
||||
temporaryValue !== null && typeof temporaryValue === 'object'
|
||||
? JSON.stringify(temporaryValue)
|
||||
: temporaryValue;
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } = useDataGridCell<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && isMultiline) {
|
||||
const textArea = inputRef.current as HTMLTextAreaElement;
|
||||
|
||||
textArea.setSelectionRange(textArea.value.length, textArea.value.length);
|
||||
}
|
||||
}, [inputRef, isEditing, isMultiline]);
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
await onSave((normalizedTemporaryValue || '').replace(/\n/gi, `\\n`));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInputKeyDown(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();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextAreaKeyDown(
|
||||
event: KeyboardEvent<HTMLTextAreaElement>,
|
||||
) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// Saving content Enter / CTRL + Enter / CMD + Enter (macOS) - but not on
|
||||
// Shift + Enter
|
||||
if (
|
||||
(!event.shiftKey && event.key === 'Enter') ||
|
||||
(event.ctrlKey && event.key === 'Enter') ||
|
||||
(event.metaKey && event.key === 'Enter')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing && isMultiline) {
|
||||
return (
|
||||
<Input
|
||||
multiline
|
||||
ref={inputRef as Ref<HTMLInputElement>}
|
||||
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleTextAreaKeyDown}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full min-h-38"
|
||||
rows={5}
|
||||
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={{
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef as Ref<HTMLInputElement>}
|
||||
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
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) {
|
||||
return (
|
||||
<Text className="truncate !text-xs" color="secondary">
|
||||
{optimisticValue === '' ? 'empty' : 'null'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopiable) {
|
||||
return (
|
||||
<div className="grid grid-flow-col items-center justify-start gap-1">
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const copiableValue =
|
||||
typeof optimisticValue === 'object'
|
||||
? JSON.stringify(optimisticValue)
|
||||
: String(optimisticValue).replace(/\\n/gi, '\n');
|
||||
|
||||
copy(copiableValue, 'Value');
|
||||
}}
|
||||
className="-ml-px min-w-0 p-0"
|
||||
aria-label="Copy value"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Text className="truncate text-xs">
|
||||
{typeof normalizedOptimisticValue === 'object'
|
||||
? JSON.stringify(normalizedOptimisticValue)
|
||||
: normalizedOptimisticValue}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className="truncate text-xs">
|
||||
{typeof normalizedOptimisticValue === 'object'
|
||||
? JSON.stringify(normalizedOptimisticValue)
|
||||
: normalizedOptimisticValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './DataGridTextCell';
|
||||
export { default as DataGridTextCell } from './DataGridTextCell';
|
||||
@@ -1,10 +1,11 @@
|
||||
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 type { PreviewProps } from '@/components/dataGrid/DataGridPreviewCell';
|
||||
import { DataGridPreviewCell } from '@/components/dataGrid/DataGridPreviewCell';
|
||||
import { DataGridTextCell } from '@/components/dataGrid/DataGridTextCell';
|
||||
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 type { PreviewProps } from '@/features/orgs/projects/storage/dataGrid/components/DataGridPreviewCell';
|
||||
import { DataGridPreviewCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridPreviewCell';
|
||||
import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell';
|
||||
|
||||
import { FilePreviewIcon } from '@/components/ui/v2/icons/FilePreviewIcon';
|
||||
import { useAppClient } from '@/features/orgs/projects/hooks/useAppClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ApplicationStatus } from "@/types/application";
|
||||
import { getHasuraAdminSecret } from "@/utils/env";
|
||||
import { type GetProjectQuery } from "@/utils/__generated__/graphql";
|
||||
import { type Org } from "@/features/orgs/projects/hooks/useOrgs";
|
||||
import { type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { getHasuraAdminSecret } from '@/utils/env';
|
||||
import { type GetProjectQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
export const localApplication: GetProjectQuery['apps'][0] = {
|
||||
id: '00000000-0000-0000-0000-000000000000',
|
||||
@@ -56,4 +56,5 @@ export const localOrganization: Org = {
|
||||
featureMaxDbSize: 1,
|
||||
},
|
||||
apps: [localApplication],
|
||||
};
|
||||
members: [],
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function useNotFoundRedirect() {
|
||||
router.pathname === '/' ||
|
||||
router.pathname === '/account' ||
|
||||
router.pathname === '/support/ticket' ||
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
|
||||
@@ -55,7 +55,7 @@ export default function ComputeFormSection({
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
vCPUs: {formValues.compute.cpu / 1000} / Memory:{' '}
|
||||
@@ -70,7 +70,7 @@ export default function ComputeFormSection({
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/guides/run/resources"
|
||||
href="https://docs.nhost.io/guides/run/resources#compute"
|
||||
className="underline"
|
||||
>
|
||||
resources
|
||||
@@ -79,7 +79,7 @@ export default function ComputeFormSection({
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
@@ -90,7 +90,7 @@ export default function ComputeFormSection({
|
||||
variant="outlined"
|
||||
onClick={decrementCompute}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
<Slider
|
||||
@@ -107,7 +107,7 @@ export default function ComputeFormSection({
|
||||
variant="outlined"
|
||||
onClick={incrementCompute}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<ArrowRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -41,8 +41,8 @@ export default function StorageFormSection() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="space-y-4 rounded border-1 p-4">
|
||||
<Box className="flex flex-row items-center justify-between ">
|
||||
<Box className="p-4 space-y-4 rounded border-1">
|
||||
<Box className="flex flex-row items-center justify-between">
|
||||
<Box className="flex flex-row items-center space-x-2">
|
||||
<Text variant="h4" className="font-semibold">
|
||||
Storage
|
||||
@@ -58,7 +58,7 @@ export default function StorageFormSection() {
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href="https://docs.nhost.io/run/storage"
|
||||
href="https://docs.nhost.io/guides/run/resources#storage"
|
||||
className="underline"
|
||||
>
|
||||
Storage
|
||||
@@ -67,7 +67,7 @@ export default function StorageFormSection() {
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoIcon aria-label="Info" className="h-4 w-4" color="primary" />
|
||||
<InfoIcon aria-label="Info" className="w-4 h-4" color="primary" />
|
||||
</Tooltip>
|
||||
</Box>
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function StorageFormSection() {
|
||||
variant="borderless"
|
||||
onClick={() => append({ name: '', capacity: 1, path: '' })}
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
@@ -138,7 +138,7 @@ export default function StorageFormSection() {
|
||||
color="error"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -3,5 +3,6 @@ mutation updateApplication($appId: uuid!, $app: apps_set_input!) {
|
||||
name
|
||||
id
|
||||
slug
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,15 @@ query getOrganizations($userId: uuid!) {
|
||||
subdomain
|
||||
slug
|
||||
}
|
||||
members {
|
||||
id
|
||||
role
|
||||
user {
|
||||
id
|
||||
email
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
dashboard/src/gql/organizations/insertOrgApp.gql
Normal file
8
dashboard/src/gql/organizations/insertOrgApp.gql
Normal file
@@ -0,0 +1,8 @@
|
||||
mutation insertOrgApplication($app: apps_insert_input!) {
|
||||
insertApp(object: $app) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,8 @@ export default function DeploymentsPage() {
|
||||
|
||||
if (!project?.githubRepository) {
|
||||
return (
|
||||
<Container className="grid max-w-3xl grid-flow-row gap-4 mt-12 antialiased text-center">
|
||||
<div className="flex flex-col mx-auto text-center w-centImage">
|
||||
<Container className="mt-12 grid max-w-3xl grid-flow-row gap-4 text-center antialiased">
|
||||
<div className="mx-auto flex w-centImage flex-col text-center">
|
||||
<Image
|
||||
src="/assets/githubRepo.svg"
|
||||
width={72}
|
||||
@@ -38,7 +38,7 @@ export default function DeploymentsPage() {
|
||||
</div>
|
||||
|
||||
<NavLink
|
||||
href={`/orgs/${org?.slug}/projects/${project?.slug}/settings/git`}
|
||||
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings/git`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
@@ -55,8 +55,8 @@ export default function DeploymentsPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Container className="flex flex-col max-w-5xl mx-auto space-y-2">
|
||||
<div className="flex flex-row mt-4 place-content-between">
|
||||
<Container className="mx-auto flex max-w-5xl flex-col space-y-2">
|
||||
<div className="mt-4 flex flex-row place-content-between">
|
||||
<Text variant="h2" component="h1">
|
||||
Deployments
|
||||
</Text>
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function SettingsAuthenticationPage() {
|
||||
|
||||
return (
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-y-6 bg-transparent"
|
||||
className="grid max-w-5xl grid-flow-row bg-transparent gap-y-6"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<AuthServiceVersionSettings />
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useBillingDeleteAppMutation,
|
||||
usePauseApplicationMutation,
|
||||
useUpdateApplicationMutation,
|
||||
@@ -40,21 +39,20 @@ export type ProjectNameValidationSchema = Yup.InferType<
|
||||
>;
|
||||
|
||||
export default function SettingsGeneralPage() {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { maintenanceActive } = useUI();
|
||||
const { openDialog, openAlertDialog, closeDialog } = useDialog();
|
||||
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { currentOrg: org } = useOrgs();
|
||||
const { project, loading, refetch: refetchProject } = useProject();
|
||||
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { openDialog, openAlertDialog, closeDialog } = useDialog();
|
||||
const [updateApp] = useUpdateApplicationMutation();
|
||||
const [deleteApplication] = useBillingDeleteAppMutation();
|
||||
const [pauseApplication] = usePauseApplicationMutation({
|
||||
variables: { appId: project?.id },
|
||||
refetchQueries: [{ query: GetAllWorkspacesAndProjectsDocument }],
|
||||
});
|
||||
const [deleteApplication] = useBillingDeleteAppMutation();
|
||||
const router = useRouter();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const form = useForm<ProjectNameValidationSchema>({
|
||||
mode: 'onSubmit',
|
||||
@@ -70,14 +68,6 @@ export default function SettingsGeneralPage() {
|
||||
const { register, formState } = form;
|
||||
|
||||
async function handleProjectNameChange(data: ProjectNameValidationSchema) {
|
||||
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `updating: true`.
|
||||
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
|
||||
// i.e. redirecting to 404 if there's no workspace/project with that slug.
|
||||
await router.replace({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, updating: true },
|
||||
});
|
||||
|
||||
const newProjectSlug = slugifyString(data.name);
|
||||
|
||||
if (newProjectSlug.length < 1 || newProjectSlug.length > 32) {
|
||||
@@ -118,11 +108,6 @@ export default function SettingsGeneralPage() {
|
||||
}
|
||||
|
||||
form.reset(undefined, { keepValues: true, keepDirty: false });
|
||||
|
||||
await refetchProject();
|
||||
await router.replace(
|
||||
`/orgs/${org?.slug}/projects/${updateAppResult?.slug}/settings`,
|
||||
);
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
@@ -151,7 +136,10 @@ export default function SettingsGeneralPage() {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await pauseApplication();
|
||||
await router.push(`/orgs/${org.slug}/projects/${project.slug}`);
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
await refetchProject();
|
||||
},
|
||||
{
|
||||
loadingMessage: `Pausing ${project.name}...`,
|
||||
@@ -209,7 +197,7 @@ export default function SettingsGeneralPage() {
|
||||
type: 'button',
|
||||
color: 'primary',
|
||||
variant: 'contained',
|
||||
disabled: maintenanceActive,
|
||||
disabled: maintenanceActive || !isPlatform,
|
||||
onClick: () => {
|
||||
openAlertDialog({
|
||||
title: 'Pause Project?',
|
||||
|
||||
@@ -30,7 +30,7 @@ export default function StoragePage() {
|
||||
: project.config?.hasura.adminSecret,
|
||||
}}
|
||||
>
|
||||
<div className="h-full pb-25 xs+:pb-[53px]">
|
||||
<div className="h-full max-w-full pb-25 xs+:pb-[56.5px]">
|
||||
<RetryableErrorBoundary>
|
||||
<FilesDataGrid />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -13,14 +13,11 @@ import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
import type {
|
||||
GetOrganizationsQuery,
|
||||
PrefetchNewAppRegionsFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useInsertApplicationMutation,
|
||||
useInsertOrgApplicationMutation,
|
||||
usePrefetchNewAppQuery,
|
||||
type GetOrganizationsQuery,
|
||||
type PrefetchNewAppRegionsFragment,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -64,9 +61,7 @@ export function NewProjectPageContent({
|
||||
|
||||
const { submitState, setSubmitState } = useSubmitState();
|
||||
|
||||
const [insertApp] = useInsertApplicationMutation({
|
||||
refetchQueries: [{ query: GetAllWorkspacesAndProjectsDocument }],
|
||||
});
|
||||
const [insertApp] = useInsertOrgApplicationMutation();
|
||||
|
||||
// options
|
||||
const orgOptions = orgs.map((org) => ({
|
||||
@@ -105,18 +100,21 @@ export function NewProjectPageContent({
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await insertApp({
|
||||
variables: {
|
||||
app: {
|
||||
name,
|
||||
slug,
|
||||
organizationID: selectedOrg.id,
|
||||
regionId: selectedRegion.id,
|
||||
const { data: { insertApp: { subdomain } = {} } = {} } =
|
||||
await insertApp({
|
||||
variables: {
|
||||
app: {
|
||||
name,
|
||||
slug,
|
||||
organizationID: selectedOrg.id,
|
||||
regionId: selectedRegion.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await router.push(`/orgs/${selectedOrg.slug}/projects/${slug}`);
|
||||
if (subdomain) {
|
||||
await router.push(`/orgs/${selectedOrg.slug}/projects/${subdomain}`);
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Creating the project...',
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
@@ -9,44 +8,58 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useWorkspaces } from '@/features/orgs/projects/hooks/useWorkspaces';
|
||||
import { InfoCard } from '@/features/projects/overview/components/InfoCard';
|
||||
import {
|
||||
useGetAllWorkspacesAndProjectsQuery,
|
||||
type GetAllWorkspacesAndProjectsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Divider } from '@mui/material';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type Workspace = Omit<
|
||||
GetAllWorkspacesAndProjectsQuery['workspaces'][0],
|
||||
'__typename'
|
||||
>;
|
||||
interface ProjectSelectorOption {
|
||||
type: 'workspace-project' | 'org-project';
|
||||
projectName: string;
|
||||
projectPathDescriptor: string;
|
||||
route: string;
|
||||
isFree: boolean;
|
||||
plan: string;
|
||||
}
|
||||
|
||||
export default function SelectWorkspaceAndProject() {
|
||||
const user = useUserData();
|
||||
const router = useRouter();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { orgs, loading: loadingOrgs } = useOrgs();
|
||||
const { workspaces, loading: loadingWorkspaces } = useWorkspaces();
|
||||
|
||||
const { data, loading } = useGetAllWorkspacesAndProjectsQuery({
|
||||
skip: !user,
|
||||
});
|
||||
const workspaceProjects: ProjectSelectorOption[] = workspaces.flatMap(
|
||||
(workspace) =>
|
||||
workspace.projects.map((project) => ({
|
||||
type: 'workspace-project',
|
||||
projectName: project.name,
|
||||
projectPathDescriptor: `${workspace.name}/${project.name}`,
|
||||
route: `${workspace.slug}/${project.slug}/services`,
|
||||
isFree: project.legacyPlan.isFree,
|
||||
plan: project.legacyPlan.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const workspaces: Workspace[] = data?.workspaces || [];
|
||||
|
||||
const projects = workspaces.flatMap((workspace) =>
|
||||
workspace.projects.map((project) => ({
|
||||
workspaceName: workspace.name,
|
||||
const orgProjects: ProjectSelectorOption[] = orgs.flatMap((org) =>
|
||||
org.apps.map((project) => ({
|
||||
type: 'org-project',
|
||||
projectName: project.name,
|
||||
value: `${workspace.slug}/${project.slug}`,
|
||||
isFree: project.legacyPlan.isFree,
|
||||
projectPathDescriptor: `${org.name}/${project.name}`,
|
||||
route: `/orgs/${org.slug}/projects/${project.subdomain}/run`,
|
||||
isFree: org.plan.isFree,
|
||||
plan: org.plan.name,
|
||||
})),
|
||||
);
|
||||
|
||||
const projects = [...orgProjects, ...workspaceProjects];
|
||||
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
@@ -89,17 +102,12 @@ export default function SelectWorkspaceAndProject() {
|
||||
}
|
||||
}, [checkConfigFromQuery, router.query]);
|
||||
|
||||
const goToServices = async (project: {
|
||||
workspaceName: string;
|
||||
projectName: string;
|
||||
value: string;
|
||||
isFree: boolean;
|
||||
}) => {
|
||||
const goToServices = async (project: ProjectSelectorOption) => {
|
||||
if (!project) {
|
||||
openAlertDialog({
|
||||
title: 'Please select a workspace and a project',
|
||||
title: 'Please select a project',
|
||||
payload:
|
||||
'You must select a workspace and a project before proceeding to create the run service',
|
||||
'You must select a project before proceeding to create the run service',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
@@ -111,8 +119,8 @@ export default function SelectWorkspaceAndProject() {
|
||||
|
||||
if (project.isFree) {
|
||||
openAlertDialog({
|
||||
title: 'The project must have a pro plan',
|
||||
payload: 'Creating run services is only availabel for pro projects',
|
||||
title: 'Cannot proceed',
|
||||
payload: 'Creating run services is only available on a Pro plan',
|
||||
props: {
|
||||
primaryButtonText: 'Ok',
|
||||
hideSecondaryAction: true,
|
||||
@@ -122,11 +130,7 @@ export default function SelectWorkspaceAndProject() {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.push({
|
||||
pathname: `/${project.value}/services`,
|
||||
// Keep the same query params that got us here
|
||||
query: router.query,
|
||||
});
|
||||
await router.push({ pathname: project.route, query: router.query });
|
||||
};
|
||||
|
||||
const projectsToDisplay = filter
|
||||
@@ -135,32 +139,27 @@ export default function SelectWorkspaceAndProject() {
|
||||
)
|
||||
: projects;
|
||||
|
||||
if (loading) {
|
||||
if (loadingWorkspaces || loadingOrgs) {
|
||||
return (
|
||||
<div className="flex w-full justify-center">
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
label="Loading workspaces and projects..."
|
||||
/>
|
||||
<div className="flex justify-center w-full">
|
||||
<ActivityIndicator delay={500} label="Loading projects..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14">
|
||||
<Text variant="h2" component="h1" className="">
|
||||
New Run Service
|
||||
</Text>
|
||||
<div className="flex flex-col items-start w-full h-full px-5 py-4 mx-auto bg-background">
|
||||
<div className="mx-auto flex h-full w-full max-w-[760px] flex-col gap-4 py-6 sm:py-14">
|
||||
<h1 className="text-2xl font-medium">New Run Service</h1>
|
||||
|
||||
<InfoCard
|
||||
title="Please select the workspace and the project where you want to create the service"
|
||||
title="Please select the project where you want to create the service"
|
||||
disableCopy
|
||||
value=""
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex w-full">
|
||||
<div className="flex w-full mb-2">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
onChange={handleFilterChange}
|
||||
@@ -170,15 +169,15 @@ export default function SelectWorkspaceAndProject() {
|
||||
</div>
|
||||
<RetryableErrorBoundary>
|
||||
{projectsToDisplay.length === 0 ? (
|
||||
<Box className="h-import py-2">
|
||||
<Box className="py-2 h-import">
|
||||
<Text variant="subtitle2">No results found.</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<List className="h-import overflow-y-auto">
|
||||
<List className="flex flex-col gap-2 overflow-y-auto h-import">
|
||||
{projectsToDisplay.map((project, index) => (
|
||||
<Fragment key={project.value}>
|
||||
<Fragment key={project.projectPathDescriptor}>
|
||||
<ListItem.Root
|
||||
className="grid grid-flow-col justify-start gap-2 py-2.5"
|
||||
className="flex flex-row items-center justify-center gap-4"
|
||||
secondaryAction={
|
||||
<Button
|
||||
variant="borderless"
|
||||
@@ -189,19 +188,35 @@ export default function SelectWorkspaceAndProject() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ListItem.Avatar>
|
||||
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
</span>
|
||||
<ListItem.Avatar className="flex items-center justify-center h-full">
|
||||
<Image
|
||||
src="/logos/new.svg"
|
||||
alt="Nhost Logo"
|
||||
className="w-10 h-10 rounded-md"
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
</ListItem.Avatar>
|
||||
<ListItem.Text
|
||||
primary={project.projectName}
|
||||
secondary={`${project.workspaceName} / ${project.projectName}`}
|
||||
primary={
|
||||
<div className="flex items-center">
|
||||
<span>{project.projectName}</span>
|
||||
<Badge
|
||||
variant={project.isFree ? 'outline' : 'default'}
|
||||
className={cn(
|
||||
'hover:none ml-2 h-5 px-[6px] text-[10px]',
|
||||
project.isFree && 'bg-muted',
|
||||
project.type === 'workspace-project' &&
|
||||
'bg-orange-200 text-foreground hover:bg-orange-200 dark:bg-orange-500',
|
||||
)}
|
||||
>
|
||||
{project.type === 'workspace-project'
|
||||
? 'Legacy'
|
||||
: project.plan}
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
secondary={project.projectPathDescriptor}
|
||||
/>
|
||||
</ListItem.Root>
|
||||
|
||||
@@ -213,7 +228,7 @@ export default function SelectWorkspaceAndProject() {
|
||||
</RetryableErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
377
dashboard/src/utils/__generated__/graphql.ts
generated
377
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -12546,6 +12546,10 @@ export type Mutation_Root = {
|
||||
delete_countries?: Maybe<Countries_Mutation_Response>;
|
||||
/** delete single row from the table: "countries" */
|
||||
delete_countries_by_pk?: Maybe<Countries>;
|
||||
/** delete data from the table: "organization_costs_thresholds" */
|
||||
delete_organization_costs_thresholds?: Maybe<Organization_Costs_Thresholds_Mutation_Response>;
|
||||
/** delete single row from the table: "organization_costs_thresholds" */
|
||||
delete_organization_costs_thresholds_by_pk?: Maybe<Organization_Costs_Thresholds>;
|
||||
/** delete data from the table: "organization_members_role" */
|
||||
delete_organization_members_role?: Maybe<Organization_Members_Role_Mutation_Response>;
|
||||
/** delete single row from the table: "organization_members_role" */
|
||||
@@ -12745,6 +12749,10 @@ export type Mutation_Root = {
|
||||
insert_countries?: Maybe<Countries_Mutation_Response>;
|
||||
/** insert a single row into the table: "countries" */
|
||||
insert_countries_one?: Maybe<Countries>;
|
||||
/** insert data into the table: "organization_costs_thresholds" */
|
||||
insert_organization_costs_thresholds?: Maybe<Organization_Costs_Thresholds_Mutation_Response>;
|
||||
/** insert a single row into the table: "organization_costs_thresholds" */
|
||||
insert_organization_costs_thresholds_one?: Maybe<Organization_Costs_Thresholds>;
|
||||
/** insert data into the table: "organization_members_role" */
|
||||
insert_organization_members_role?: Maybe<Organization_Members_Role_Mutation_Response>;
|
||||
/** insert a single row into the table: "organization_members_role" */
|
||||
@@ -12770,6 +12778,9 @@ export type Mutation_Root = {
|
||||
replaceRunServiceConfig: ConfigRunServiceConfig;
|
||||
resetPostgresPassword: Scalars['Boolean'];
|
||||
restoreApplicationDatabase: Scalars['Boolean'];
|
||||
sendEmailInvite: Scalars['Boolean'];
|
||||
sendEmailOrganizationStatusChange: Scalars['Boolean'];
|
||||
sendEmailOrganizationThreshold: Scalars['Boolean'];
|
||||
sendEmailTemplate: Scalars['Boolean'];
|
||||
/** update single row of the table: "apps" */
|
||||
updateApp?: Maybe<Apps>;
|
||||
@@ -13029,6 +13040,12 @@ export type Mutation_Root = {
|
||||
update_githubRepositories_many?: Maybe<Array<Maybe<GithubRepositories_Mutation_Response>>>;
|
||||
/** update multiples rows of table: "billing.subscriptions" */
|
||||
update_many_billing_subscriptions?: Maybe<Array<Maybe<Billing_Subscriptions_Mutation_Response>>>;
|
||||
/** update data of the table: "organization_costs_thresholds" */
|
||||
update_organization_costs_thresholds?: Maybe<Organization_Costs_Thresholds_Mutation_Response>;
|
||||
/** update single row of the table: "organization_costs_thresholds" */
|
||||
update_organization_costs_thresholds_by_pk?: Maybe<Organization_Costs_Thresholds>;
|
||||
/** update multiples rows of table: "organization_costs_thresholds" */
|
||||
update_organization_costs_thresholds_many?: Maybe<Array<Maybe<Organization_Costs_Thresholds_Mutation_Response>>>;
|
||||
/** update data of the table: "organization_members_role" */
|
||||
update_organization_members_role?: Maybe<Organization_Members_Role_Mutation_Response>;
|
||||
/** update single row of the table: "organization_members_role" */
|
||||
@@ -13748,6 +13765,18 @@ export type Mutation_RootDelete_Countries_By_PkArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootDelete_Organization_Costs_ThresholdsArgs = {
|
||||
where: Organization_Costs_Thresholds_Bool_Exp;
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootDelete_Organization_Costs_Thresholds_By_PkArgs = {
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootDelete_Organization_Members_RoleArgs = {
|
||||
where: Organization_Members_Role_Bool_Exp;
|
||||
@@ -14450,6 +14479,20 @@ export type Mutation_RootInsert_Countries_OneArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootInsert_Organization_Costs_ThresholdsArgs = {
|
||||
objects: Array<Organization_Costs_Thresholds_Insert_Input>;
|
||||
on_conflict?: InputMaybe<Organization_Costs_Thresholds_On_Conflict>;
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootInsert_Organization_Costs_Thresholds_OneArgs = {
|
||||
object: Organization_Costs_Thresholds_Insert_Input;
|
||||
on_conflict?: InputMaybe<Organization_Costs_Thresholds_On_Conflict>;
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootInsert_Organization_Members_RoleArgs = {
|
||||
objects: Array<Organization_Members_Role_Insert_Input>;
|
||||
@@ -14553,6 +14596,27 @@ export type Mutation_RootRestoreApplicationDatabaseArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootSendEmailInviteArgs = {
|
||||
inviteID: Scalars['uuid'];
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootSendEmailOrganizationStatusChangeArgs = {
|
||||
newStatus: Scalars['String'];
|
||||
oldStatus: Scalars['String'];
|
||||
organizationID: Scalars['uuid'];
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootSendEmailOrganizationThresholdArgs = {
|
||||
organizationID: Scalars['uuid'];
|
||||
threshold: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootSendEmailTemplateArgs = {
|
||||
from: Scalars['String'];
|
||||
@@ -15525,6 +15589,26 @@ export type Mutation_RootUpdate_Many_Billing_SubscriptionsArgs = {
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootUpdate_Organization_Costs_ThresholdsArgs = {
|
||||
_set?: InputMaybe<Organization_Costs_Thresholds_Set_Input>;
|
||||
where: Organization_Costs_Thresholds_Bool_Exp;
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootUpdate_Organization_Costs_Thresholds_By_PkArgs = {
|
||||
_set?: InputMaybe<Organization_Costs_Thresholds_Set_Input>;
|
||||
pk_columns: Organization_Costs_Thresholds_Pk_Columns_Input;
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootUpdate_Organization_Costs_Thresholds_ManyArgs = {
|
||||
updates: Array<Organization_Costs_Thresholds_Updates>;
|
||||
};
|
||||
|
||||
|
||||
/** mutation root */
|
||||
export type Mutation_RootUpdate_Organization_Members_RoleArgs = {
|
||||
_set?: InputMaybe<Organization_Members_Role_Set_Input>;
|
||||
@@ -15672,6 +15756,160 @@ export type OrganizationMemberInviteAccept_Args = {
|
||||
id?: InputMaybe<Scalars['uuid']>;
|
||||
};
|
||||
|
||||
/** columns and relationships of "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds = {
|
||||
__typename?: 'organization_costs_thresholds';
|
||||
comment?: Maybe<Scalars['String']>;
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
/** aggregated selection of "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Aggregate = {
|
||||
__typename?: 'organization_costs_thresholds_aggregate';
|
||||
aggregate?: Maybe<Organization_Costs_Thresholds_Aggregate_Fields>;
|
||||
nodes: Array<Organization_Costs_Thresholds>;
|
||||
};
|
||||
|
||||
/** aggregate fields of "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Aggregate_Fields = {
|
||||
__typename?: 'organization_costs_thresholds_aggregate_fields';
|
||||
count: Scalars['Int'];
|
||||
max?: Maybe<Organization_Costs_Thresholds_Max_Fields>;
|
||||
min?: Maybe<Organization_Costs_Thresholds_Min_Fields>;
|
||||
};
|
||||
|
||||
|
||||
/** aggregate fields of "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Aggregate_FieldsCountArgs = {
|
||||
columns?: InputMaybe<Array<Organization_Costs_Thresholds_Select_Column>>;
|
||||
distinct?: InputMaybe<Scalars['Boolean']>;
|
||||
};
|
||||
|
||||
/** Boolean expression to filter rows from the table "organization_costs_thresholds". All fields are combined with a logical 'AND'. */
|
||||
export type Organization_Costs_Thresholds_Bool_Exp = {
|
||||
_and?: InputMaybe<Array<Organization_Costs_Thresholds_Bool_Exp>>;
|
||||
_not?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
_or?: InputMaybe<Array<Organization_Costs_Thresholds_Bool_Exp>>;
|
||||
comment?: InputMaybe<String_Comparison_Exp>;
|
||||
value?: InputMaybe<String_Comparison_Exp>;
|
||||
};
|
||||
|
||||
/** unique or primary key constraints on table "organization_costs_thresholds" */
|
||||
export enum Organization_Costs_Thresholds_Constraint {
|
||||
/** unique or primary key constraint on columns "value" */
|
||||
OrganizationCostsThresholdsPkey = 'organization_costs_thresholds_pkey'
|
||||
}
|
||||
|
||||
export enum Organization_Costs_Thresholds_Enum {
|
||||
/** No threshold reached */
|
||||
None = 'NONE',
|
||||
/** 75% of cost usage reached */
|
||||
Percent_075 = 'PERCENT_075',
|
||||
/** 90% of cost usage reached */
|
||||
Percent_090 = 'PERCENT_090',
|
||||
/** 100% of cost usage reached */
|
||||
Percent_100 = 'PERCENT_100'
|
||||
}
|
||||
|
||||
/** Boolean expression to compare columns of type "organization_costs_thresholds_enum". All fields are combined with logical 'AND'. */
|
||||
export type Organization_Costs_Thresholds_Enum_Comparison_Exp = {
|
||||
_eq?: InputMaybe<Organization_Costs_Thresholds_Enum>;
|
||||
_in?: InputMaybe<Array<Organization_Costs_Thresholds_Enum>>;
|
||||
_is_null?: InputMaybe<Scalars['Boolean']>;
|
||||
_neq?: InputMaybe<Organization_Costs_Thresholds_Enum>;
|
||||
_nin?: InputMaybe<Array<Organization_Costs_Thresholds_Enum>>;
|
||||
};
|
||||
|
||||
/** input type for inserting data into table "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Insert_Input = {
|
||||
comment?: InputMaybe<Scalars['String']>;
|
||||
value?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** aggregate max on columns */
|
||||
export type Organization_Costs_Thresholds_Max_Fields = {
|
||||
__typename?: 'organization_costs_thresholds_max_fields';
|
||||
comment?: Maybe<Scalars['String']>;
|
||||
value?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** aggregate min on columns */
|
||||
export type Organization_Costs_Thresholds_Min_Fields = {
|
||||
__typename?: 'organization_costs_thresholds_min_fields';
|
||||
comment?: Maybe<Scalars['String']>;
|
||||
value?: Maybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** response of any mutation on the table "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Mutation_Response = {
|
||||
__typename?: 'organization_costs_thresholds_mutation_response';
|
||||
/** number of rows affected by the mutation */
|
||||
affected_rows: Scalars['Int'];
|
||||
/** data from the rows affected by the mutation */
|
||||
returning: Array<Organization_Costs_Thresholds>;
|
||||
};
|
||||
|
||||
/** on_conflict condition type for table "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_On_Conflict = {
|
||||
constraint: Organization_Costs_Thresholds_Constraint;
|
||||
update_columns?: Array<Organization_Costs_Thresholds_Update_Column>;
|
||||
where?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
};
|
||||
|
||||
/** Ordering options when selecting data from "organization_costs_thresholds". */
|
||||
export type Organization_Costs_Thresholds_Order_By = {
|
||||
comment?: InputMaybe<Order_By>;
|
||||
value?: InputMaybe<Order_By>;
|
||||
};
|
||||
|
||||
/** primary key columns input for table: organization_costs_thresholds */
|
||||
export type Organization_Costs_Thresholds_Pk_Columns_Input = {
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
/** select columns of table "organization_costs_thresholds" */
|
||||
export enum Organization_Costs_Thresholds_Select_Column {
|
||||
/** column name */
|
||||
Comment = 'comment',
|
||||
/** column name */
|
||||
Value = 'value'
|
||||
}
|
||||
|
||||
/** input type for updating data in table "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Set_Input = {
|
||||
comment?: InputMaybe<Scalars['String']>;
|
||||
value?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** Streaming cursor of the table "organization_costs_thresholds" */
|
||||
export type Organization_Costs_Thresholds_Stream_Cursor_Input = {
|
||||
/** Stream column input with initial value */
|
||||
initial_value: Organization_Costs_Thresholds_Stream_Cursor_Value_Input;
|
||||
/** cursor ordering */
|
||||
ordering?: InputMaybe<Cursor_Ordering>;
|
||||
};
|
||||
|
||||
/** Initial value of the column from where the streaming should start */
|
||||
export type Organization_Costs_Thresholds_Stream_Cursor_Value_Input = {
|
||||
comment?: InputMaybe<Scalars['String']>;
|
||||
value?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
/** update columns of table "organization_costs_thresholds" */
|
||||
export enum Organization_Costs_Thresholds_Update_Column {
|
||||
/** column name */
|
||||
Comment = 'comment',
|
||||
/** column name */
|
||||
Value = 'value'
|
||||
}
|
||||
|
||||
export type Organization_Costs_Thresholds_Updates = {
|
||||
/** sets the columns of the filtered rows to the given values */
|
||||
_set?: InputMaybe<Organization_Costs_Thresholds_Set_Input>;
|
||||
/** filter the rows which have to be updated */
|
||||
where: Organization_Costs_Thresholds_Bool_Exp;
|
||||
};
|
||||
|
||||
/** columns and relationships of "organization_member_invites" */
|
||||
export type Organization_Member_Invites = {
|
||||
__typename?: 'organization_member_invites';
|
||||
@@ -16666,7 +16904,7 @@ export type Organizations = {
|
||||
/** An aggregate relationship */
|
||||
apps_aggregate: Apps_Aggregate;
|
||||
createdAt: Scalars['timestamptz'];
|
||||
current_threshold: Scalars['String'];
|
||||
current_threshold: Organization_Costs_Thresholds_Enum;
|
||||
id: Scalars['uuid'];
|
||||
/** An array relationship */
|
||||
invites: Array<Organization_Member_Invites>;
|
||||
@@ -16853,7 +17091,7 @@ export type Organizations_Bool_Exp = {
|
||||
apps?: InputMaybe<Apps_Bool_Exp>;
|
||||
apps_aggregate?: InputMaybe<Apps_Aggregate_Bool_Exp>;
|
||||
createdAt?: InputMaybe<Timestamptz_Comparison_Exp>;
|
||||
current_threshold?: InputMaybe<String_Comparison_Exp>;
|
||||
current_threshold?: InputMaybe<Organization_Costs_Thresholds_Enum_Comparison_Exp>;
|
||||
id?: InputMaybe<Uuid_Comparison_Exp>;
|
||||
invites?: InputMaybe<Organization_Member_Invites_Bool_Exp>;
|
||||
invites_aggregate?: InputMaybe<Organization_Member_Invites_Aggregate_Bool_Exp>;
|
||||
@@ -16889,7 +17127,7 @@ export type Organizations_Insert_Input = {
|
||||
allowedPrivateRegions?: InputMaybe<Regions_Allowed_Organization_Arr_Rel_Insert_Input>;
|
||||
apps?: InputMaybe<Apps_Arr_Rel_Insert_Input>;
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
current_threshold?: InputMaybe<Scalars['String']>;
|
||||
current_threshold?: InputMaybe<Organization_Costs_Thresholds_Enum>;
|
||||
id?: InputMaybe<Scalars['uuid']>;
|
||||
invites?: InputMaybe<Organization_Member_Invites_Arr_Rel_Insert_Input>;
|
||||
members?: InputMaybe<Organization_Members_Arr_Rel_Insert_Input>;
|
||||
@@ -16909,7 +17147,6 @@ export type Organizations_Insert_Input = {
|
||||
export type Organizations_Max_Fields = {
|
||||
__typename?: 'organizations_max_fields';
|
||||
createdAt?: Maybe<Scalars['timestamptz']>;
|
||||
current_threshold?: Maybe<Scalars['String']>;
|
||||
id?: Maybe<Scalars['uuid']>;
|
||||
name?: Maybe<Scalars['String']>;
|
||||
planID?: Maybe<Scalars['uuid']>;
|
||||
@@ -16924,7 +17161,6 @@ export type Organizations_Max_Fields = {
|
||||
/** order by max() on columns of table "organizations" */
|
||||
export type Organizations_Max_Order_By = {
|
||||
createdAt?: InputMaybe<Order_By>;
|
||||
current_threshold?: InputMaybe<Order_By>;
|
||||
id?: InputMaybe<Order_By>;
|
||||
name?: InputMaybe<Order_By>;
|
||||
planID?: InputMaybe<Order_By>;
|
||||
@@ -16940,7 +17176,6 @@ export type Organizations_Max_Order_By = {
|
||||
export type Organizations_Min_Fields = {
|
||||
__typename?: 'organizations_min_fields';
|
||||
createdAt?: Maybe<Scalars['timestamptz']>;
|
||||
current_threshold?: Maybe<Scalars['String']>;
|
||||
id?: Maybe<Scalars['uuid']>;
|
||||
name?: Maybe<Scalars['String']>;
|
||||
planID?: Maybe<Scalars['uuid']>;
|
||||
@@ -16955,7 +17190,6 @@ export type Organizations_Min_Fields = {
|
||||
/** order by min() on columns of table "organizations" */
|
||||
export type Organizations_Min_Order_By = {
|
||||
createdAt?: InputMaybe<Order_By>;
|
||||
current_threshold?: InputMaybe<Order_By>;
|
||||
id?: InputMaybe<Order_By>;
|
||||
name?: InputMaybe<Order_By>;
|
||||
planID?: InputMaybe<Order_By>;
|
||||
@@ -17047,7 +17281,7 @@ export enum Organizations_Select_Column {
|
||||
/** input type for updating data in table "organizations" */
|
||||
export type Organizations_Set_Input = {
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
current_threshold?: InputMaybe<Scalars['String']>;
|
||||
current_threshold?: InputMaybe<Organization_Costs_Thresholds_Enum>;
|
||||
id?: InputMaybe<Scalars['uuid']>;
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
planID?: InputMaybe<Scalars['uuid']>;
|
||||
@@ -17104,7 +17338,7 @@ export type Organizations_Stream_Cursor_Input = {
|
||||
/** Initial value of the column from where the streaming should start */
|
||||
export type Organizations_Stream_Cursor_Value_Input = {
|
||||
createdAt?: InputMaybe<Scalars['timestamptz']>;
|
||||
current_threshold?: InputMaybe<Scalars['String']>;
|
||||
current_threshold?: InputMaybe<Organization_Costs_Thresholds_Enum>;
|
||||
id?: InputMaybe<Scalars['uuid']>;
|
||||
name?: InputMaybe<Scalars['String']>;
|
||||
planID?: InputMaybe<Scalars['uuid']>;
|
||||
@@ -18538,6 +18772,12 @@ export type Query_Root = {
|
||||
organizationNewRequests: Array<Organization_New_Request>;
|
||||
/** fetch aggregated fields from the table: "organization_new_request" */
|
||||
organizationNewRequestsAggregate: Organization_New_Request_Aggregate;
|
||||
/** fetch data from the table: "organization_costs_thresholds" */
|
||||
organization_costs_thresholds: Array<Organization_Costs_Thresholds>;
|
||||
/** fetch aggregated fields from the table: "organization_costs_thresholds" */
|
||||
organization_costs_thresholds_aggregate: Organization_Costs_Thresholds_Aggregate;
|
||||
/** fetch data from the table: "organization_costs_thresholds" using primary key columns */
|
||||
organization_costs_thresholds_by_pk?: Maybe<Organization_Costs_Thresholds>;
|
||||
/** fetch data from the table: "organization_members_role" */
|
||||
organization_members_role: Array<Organization_Members_Role>;
|
||||
/** fetch aggregated fields from the table: "organization_members_role" */
|
||||
@@ -19547,6 +19787,29 @@ export type Query_RootOrganizationNewRequestsAggregateArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootOrganization_Costs_ThresholdsArgs = {
|
||||
distinct_on?: InputMaybe<Array<Organization_Costs_Thresholds_Select_Column>>;
|
||||
limit?: InputMaybe<Scalars['Int']>;
|
||||
offset?: InputMaybe<Scalars['Int']>;
|
||||
order_by?: InputMaybe<Array<Organization_Costs_Thresholds_Order_By>>;
|
||||
where?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootOrganization_Costs_Thresholds_AggregateArgs = {
|
||||
distinct_on?: InputMaybe<Array<Organization_Costs_Thresholds_Select_Column>>;
|
||||
limit?: InputMaybe<Scalars['Int']>;
|
||||
offset?: InputMaybe<Scalars['Int']>;
|
||||
order_by?: InputMaybe<Array<Organization_Costs_Thresholds_Order_By>>;
|
||||
where?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootOrganization_Costs_Thresholds_By_PkArgs = {
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type Query_RootOrganization_Members_RoleArgs = {
|
||||
distinct_on?: InputMaybe<Array<Organization_Members_Role_Select_Column>>;
|
||||
limit?: InputMaybe<Scalars['Int']>;
|
||||
@@ -21989,6 +22252,14 @@ export type Subscription_Root = {
|
||||
organizationNewRequestsAggregate: Organization_New_Request_Aggregate;
|
||||
/** fetch data from the table in a streaming manner: "organization_new_request" */
|
||||
organizationNewRequestsStream: Array<Organization_New_Request>;
|
||||
/** fetch data from the table: "organization_costs_thresholds" */
|
||||
organization_costs_thresholds: Array<Organization_Costs_Thresholds>;
|
||||
/** fetch aggregated fields from the table: "organization_costs_thresholds" */
|
||||
organization_costs_thresholds_aggregate: Organization_Costs_Thresholds_Aggregate;
|
||||
/** fetch data from the table: "organization_costs_thresholds" using primary key columns */
|
||||
organization_costs_thresholds_by_pk?: Maybe<Organization_Costs_Thresholds>;
|
||||
/** fetch data from the table in a streaming manner: "organization_costs_thresholds" */
|
||||
organization_costs_thresholds_stream: Array<Organization_Costs_Thresholds>;
|
||||
/** fetch data from the table: "organization_members_role" */
|
||||
organization_members_role: Array<Organization_Members_Role>;
|
||||
/** fetch aggregated fields from the table: "organization_members_role" */
|
||||
@@ -23121,6 +23392,36 @@ export type Subscription_RootOrganizationNewRequestsStreamArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type Subscription_RootOrganization_Costs_ThresholdsArgs = {
|
||||
distinct_on?: InputMaybe<Array<Organization_Costs_Thresholds_Select_Column>>;
|
||||
limit?: InputMaybe<Scalars['Int']>;
|
||||
offset?: InputMaybe<Scalars['Int']>;
|
||||
order_by?: InputMaybe<Array<Organization_Costs_Thresholds_Order_By>>;
|
||||
where?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
};
|
||||
|
||||
|
||||
export type Subscription_RootOrganization_Costs_Thresholds_AggregateArgs = {
|
||||
distinct_on?: InputMaybe<Array<Organization_Costs_Thresholds_Select_Column>>;
|
||||
limit?: InputMaybe<Scalars['Int']>;
|
||||
offset?: InputMaybe<Scalars['Int']>;
|
||||
order_by?: InputMaybe<Array<Organization_Costs_Thresholds_Order_By>>;
|
||||
where?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
};
|
||||
|
||||
|
||||
export type Subscription_RootOrganization_Costs_Thresholds_By_PkArgs = {
|
||||
value: Scalars['String'];
|
||||
};
|
||||
|
||||
|
||||
export type Subscription_RootOrganization_Costs_Thresholds_StreamArgs = {
|
||||
batch_size: Scalars['Int'];
|
||||
cursor: Array<InputMaybe<Organization_Costs_Thresholds_Stream_Cursor_Input>>;
|
||||
where?: InputMaybe<Organization_Costs_Thresholds_Bool_Exp>;
|
||||
};
|
||||
|
||||
|
||||
export type Subscription_RootOrganization_Members_RoleArgs = {
|
||||
distinct_on?: InputMaybe<Array<Organization_Members_Role_Select_Column>>;
|
||||
limit?: InputMaybe<Scalars['Int']>;
|
||||
@@ -26116,7 +26417,7 @@ export type UpdateApplicationMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateApplicationMutation = { __typename?: 'mutation_root', updateApp?: { __typename?: 'apps', name: string, id: any, slug: string } | null };
|
||||
export type UpdateApplicationMutation = { __typename?: 'mutation_root', updateApp?: { __typename?: 'apps', name: string, id: any, slug: string, subdomain: string } | null };
|
||||
|
||||
export type GetCountriesQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -26343,7 +26644,7 @@ export type GetOrganizationsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetOrganizationsQuery = { __typename?: 'query_root', organizations: Array<{ __typename?: 'organizations', id: any, name: string, slug: string, plan: { __typename?: 'plans', id: any, name: string, price: number, deprecated: boolean, individual: boolean, isFree: boolean, featureMaxDbSize: number }, apps: Array<{ __typename?: 'apps', id: any, name: string, subdomain: string, slug: string }> }> };
|
||||
export type GetOrganizationsQuery = { __typename?: 'query_root', organizations: Array<{ __typename?: 'organizations', id: any, name: string, slug: string, plan: { __typename?: 'plans', id: any, name: string, price: number, deprecated: boolean, individual: boolean, isFree: boolean, featureMaxDbSize: number }, apps: Array<{ __typename?: 'apps', id: any, name: string, subdomain: string, slug: string }>, members: Array<{ __typename?: 'organization_members', id: any, role: Organization_Members_Role_Enum, user: { __typename?: 'users', id: any, email?: any | null, displayName: string, avatarUrl: string } }> }> };
|
||||
|
||||
export type GetOrganizationPlansQueryVariables = Exact<{ [key: string]: never; }>;
|
||||
|
||||
@@ -26364,6 +26665,13 @@ export type GetProjectsQueryVariables = Exact<{
|
||||
|
||||
export type GetProjectsQuery = { __typename?: 'query_root', apps: Array<{ __typename?: 'apps', id: any, name: string, slug: string, createdAt: any, subdomain: string, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> };
|
||||
|
||||
export type InsertOrgApplicationMutationVariables = Exact<{
|
||||
app: Apps_Insert_Input;
|
||||
}>;
|
||||
|
||||
|
||||
export type InsertOrgApplicationMutation = { __typename?: 'mutation_root', insertApp?: { __typename?: 'apps', id: any, name: string, slug: string, subdomain: string } | null };
|
||||
|
||||
export type InsertOrganizationMemberInviteMutationVariables = Exact<{
|
||||
organizationMemberInvite: Organization_Member_Invites_Insert_Input;
|
||||
}>;
|
||||
@@ -29406,6 +29714,7 @@ export const UpdateApplicationDocument = gql`
|
||||
name
|
||||
id
|
||||
slug
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -30549,6 +30858,16 @@ export const GetOrganizationsDocument = gql`
|
||||
subdomain
|
||||
slug
|
||||
}
|
||||
members {
|
||||
id
|
||||
role
|
||||
user {
|
||||
id
|
||||
email
|
||||
displayName
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -30793,6 +31112,42 @@ export type GetProjectsQueryResult = Apollo.QueryResult<GetProjectsQuery, GetPro
|
||||
export function refetchGetProjectsQuery(variables: GetProjectsQueryVariables) {
|
||||
return { query: GetProjectsDocument, variables: variables }
|
||||
}
|
||||
export const InsertOrgApplicationDocument = gql`
|
||||
mutation insertOrgApplication($app: apps_insert_input!) {
|
||||
insertApp(object: $app) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
`;
|
||||
export type InsertOrgApplicationMutationFn = Apollo.MutationFunction<InsertOrgApplicationMutation, InsertOrgApplicationMutationVariables>;
|
||||
|
||||
/**
|
||||
* __useInsertOrgApplicationMutation__
|
||||
*
|
||||
* To run a mutation, you first call `useInsertOrgApplicationMutation` within a React component and pass it any options that fit your needs.
|
||||
* When your component renders, `useInsertOrgApplicationMutation` returns a tuple that includes:
|
||||
* - A mutate function that you can call at any time to execute the mutation
|
||||
* - An object with fields that represent the current status of the mutation's execution
|
||||
*
|
||||
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
|
||||
*
|
||||
* @example
|
||||
* const [insertOrgApplicationMutation, { data, loading, error }] = useInsertOrgApplicationMutation({
|
||||
* variables: {
|
||||
* app: // value for 'app'
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useInsertOrgApplicationMutation(baseOptions?: Apollo.MutationHookOptions<InsertOrgApplicationMutation, InsertOrgApplicationMutationVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useMutation<InsertOrgApplicationMutation, InsertOrgApplicationMutationVariables>(InsertOrgApplicationDocument, options);
|
||||
}
|
||||
export type InsertOrgApplicationMutationHookResult = ReturnType<typeof useInsertOrgApplicationMutation>;
|
||||
export type InsertOrgApplicationMutationResult = Apollo.MutationResult<InsertOrgApplicationMutation>;
|
||||
export type InsertOrgApplicationMutationOptions = Apollo.BaseMutationOptions<InsertOrgApplicationMutation, InsertOrgApplicationMutationVariables>;
|
||||
export const InsertOrganizationMemberInviteDocument = gql`
|
||||
mutation insertOrganizationMemberInvite($organizationMemberInvite: organization_member_invites_insert_input!) {
|
||||
insertOrganizationMemberInvite(object: $organizationMemberInvite) {
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.20.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- e5f1c6c: fix: copy to clipboard commands in nhost cli getting started
|
||||
|
||||
## 2.19.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -9,7 +9,7 @@ icon: square-terminal
|
||||
To install the Nhost CLI copy the following command and paste it into your terminal:
|
||||
|
||||
```bash
|
||||
> sudo curl -L https://raw.githubusercontent.com/nhost/cli/main/get.sh | bash
|
||||
sudo curl -L https://raw.githubusercontent.com/nhost/cli/main/get.sh | bash
|
||||
```
|
||||
|
||||
The `get.sh` script checks for both the architecture and operating system and installs the right binary.
|
||||
@@ -31,7 +31,7 @@ The `get.sh` script checks for both the architecture and operating system and in
|
||||
Update an existing installation to the latest version.
|
||||
|
||||
```bash Terminal
|
||||
> nhost sw upgrade
|
||||
nhost sw upgrade
|
||||
```
|
||||
|
||||
## Running Nhost
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "2.19.0",
|
||||
"version": "2.20.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "mintlify dev"
|
||||
|
||||
Reference in New Issue
Block a user