Compare commits
126 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
899732f280 | ||
|
|
037b566e39 | ||
|
|
829f20c83c | ||
|
|
f1b5a944a3 | ||
|
|
5ccb764ae5 | ||
|
|
ef2b639734 | ||
|
|
a5b895a827 | ||
|
|
b441b4bae2 | ||
|
|
a6c67c1e4c | ||
|
|
7f1785ac0f | ||
|
|
ec74e7fe98 | ||
|
|
6713c198c6 | ||
|
|
35a6b9cf47 | ||
|
|
79f97fad76 | ||
|
|
2faf79077d | ||
|
|
4972b6feb6 | ||
|
|
23d5861c4c | ||
|
|
098ac5a71c | ||
|
|
3a15329cfd | ||
|
|
c3e798aa1d | ||
|
|
eec5e6a93d | ||
|
|
d964b689cd | ||
|
|
1e080c1af5 | ||
|
|
177bba7ec0 | ||
|
|
a593b45dc2 | ||
|
|
b384fb8bd8 | ||
|
|
abd8620ded | ||
|
|
e62ccdcaae | ||
|
|
46d01b09d6 | ||
|
|
ff74e712f8 | ||
|
|
770794ccad | ||
|
|
aa80d1795d | ||
|
|
eaa7720c65 | ||
|
|
7f447d1182 | ||
|
|
5d3dd84762 | ||
|
|
c625317342 | ||
|
|
117398f5dc | ||
|
|
4e421eb4bd | ||
|
|
771447b089 | ||
|
|
8ab75a4146 | ||
|
|
607f465616 | ||
|
|
668c877130 | ||
|
|
4bd870eb96 | ||
|
|
39b3161d91 | ||
|
|
ae090a6585 | ||
|
|
be4831ae62 | ||
|
|
4fb0c18c32 | ||
|
|
22cdd7f8d7 | ||
|
|
f3a91a1f76 | ||
|
|
1e9b92fcf8 | ||
|
|
6cc56066c2 | ||
|
|
99e80cea44 | ||
|
|
f2f1c01e3b | ||
|
|
2c0f98e85c | ||
|
|
a3ad84925c | ||
|
|
b8611b6a1c | ||
|
|
a0e3030005 | ||
|
|
0cf1f1d938 | ||
|
|
88f026066f | ||
|
|
185bef878d | ||
|
|
a1c7b00e74 | ||
|
|
6da4562e79 | ||
|
|
e44cfcb2f2 | ||
|
|
23fabaf8a6 | ||
|
|
f4dca9836f | ||
|
|
f2704ea149 | ||
|
|
dd1b053212 | ||
|
|
d4ccc65655 | ||
|
|
2c2570fc82 | ||
|
|
a60f26966b | ||
|
|
a988de2d61 | ||
|
|
de54ca460e | ||
|
|
afdffab743 | ||
|
|
4c61520397 | ||
|
|
f02cd444d5 | ||
|
|
7f45a51aca | ||
|
|
08e70b9df9 | ||
|
|
20a83362ee | ||
|
|
20b800c3e4 | ||
|
|
bfaa5b4c4a | ||
|
|
a1a00b33ad | ||
|
|
a269f4ca3f | ||
|
|
baaa510309 | ||
|
|
a84aa5ad68 | ||
|
|
4191b933c9 | ||
|
|
cf2264ce1d | ||
|
|
02dd9dd8c0 | ||
|
|
d4ff25df0f | ||
|
|
3d74374780 | ||
|
|
7063af678c | ||
|
|
2b44a1cf27 | ||
|
|
c4f60b3645 | ||
|
|
f86f658aa5 | ||
|
|
bd02bd3f3e | ||
|
|
a133faa797 | ||
|
|
bb0269691d | ||
|
|
8d6171d22d | ||
|
|
fff178d79f | ||
|
|
5e5e454ae7 | ||
|
|
ce005f6d9e | ||
|
|
85889ee882 | ||
|
|
351873059e | ||
|
|
8ccfc10522 | ||
|
|
82b02ca70b | ||
|
|
14fc132040 | ||
|
|
a35da349ed | ||
|
|
302e1d9d33 | ||
|
|
0db40184e8 | ||
|
|
d38649494e | ||
|
|
5f22f1b5e5 | ||
|
|
494f93a4bf | ||
|
|
84c8af232c | ||
|
|
7f101d54da | ||
|
|
75b497412e | ||
|
|
5bd774afbb | ||
|
|
cdfe203fe4 | ||
|
|
4c7d32e944 | ||
|
|
447c622fc0 | ||
|
|
03f22aed72 | ||
|
|
ede5abf2ac | ||
|
|
0bdd1d0e0c | ||
|
|
61de7b21fd | ||
|
|
4b6ead1b17 | ||
|
|
0b193e6310 | ||
|
|
b21a5403fe | ||
|
|
e8320be941 |
@@ -81,7 +81,7 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
group: ['@testing-library/react*'],
|
||||
message: 'Please use @/utils/testUtils instead.',
|
||||
message: 'Please use @/tests/testUtils instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 0.15.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 2faf7907: chore(deps): bump `graphql-request` to v6
|
||||
- f1b5a944: chore(deps): bump `@vitejs/plugin-react` to v4
|
||||
- 7f1785ac: chore(deps): bump `@types/react` to v18.0.37
|
||||
- @nhost/react-apollo@5.0.19
|
||||
|
||||
## 0.15.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 85889ee8: feat(dashboard): add Compute management to the settings
|
||||
|
||||
## 0.14.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 668c8771: chore(dialogs): unify dialog management of payment dialogs
|
||||
|
||||
## 0.14.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d4ccc656: chore: cleanup unused code
|
||||
- @nhost/react-apollo@5.0.18
|
||||
- @nhost/nextjs@1.13.21
|
||||
|
||||
## 0.14.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "0.14.6",
|
||||
"version": "0.15.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -8,7 +8,7 @@
|
||||
"build": "next build --no-lint",
|
||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||
"start": "next start",
|
||||
"lint": "next lint --max-warnings 1",
|
||||
"lint": "next lint --max-warnings 0",
|
||||
"test": "vitest",
|
||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||
"nhost:dev": "nhost dev -d",
|
||||
@@ -51,7 +51,7 @@
|
||||
"generate-password": "^1.7.0",
|
||||
"graphiql": "^2.4.0",
|
||||
"graphql": "^16.6.0",
|
||||
"graphql-request": "^4.3.0",
|
||||
"graphql-request": "^6.0.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.11.2",
|
||||
"just-kebab-case": "^4.1.1",
|
||||
@@ -105,14 +105,14 @@
|
||||
"@types/lodash.debounce": "^4.0.7",
|
||||
"@types/node": "^16.11.7",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/react": "18.0.34",
|
||||
"@types/react": "18.0.37",
|
||||
"@types/react-dom": "18.0.11",
|
||||
"@types/react-table": "^7.7.12",
|
||||
"@types/testing-library__jest-dom": "^5.14.5",
|
||||
"@types/validator": "^13.7.10",
|
||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||
"@typescript-eslint/parser": "^5.43.0",
|
||||
"@vitejs/plugin-react": "^3.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0",
|
||||
"@vitest/coverage-c8": "^0.30.0",
|
||||
"autoprefixer": "^10.4.13",
|
||||
"babel-loader": "^8.3.0",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetOneUserDocument,
|
||||
useDeleteApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
@@ -11,6 +10,7 @@ import Text from '@/ui/v2/Text';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { getApplicationStatusString } from '@/utils/helpers';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { formatDistance } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -18,7 +18,7 @@ import { toast } from 'react-hot-toast';
|
||||
export default function ApplicationInfo() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
refetchQueries: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function ApplicationInfo() {
|
||||
'An error occurred while deleting the project. Please try again.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
await router.push('/');
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Container from '@/components/layout/Container';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetOneUserDocument,
|
||||
useGetFreeAndActiveProjectsQuery,
|
||||
useUnpauseApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
@@ -26,8 +25,12 @@ import { toast } from 'react-hot-toast';
|
||||
import { RemoveApplicationModal } from './RemoveApplicationModal';
|
||||
|
||||
export default function ApplicationPaused() {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { openDialog } = useDialog();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
refetch: refetchWorkspaceAndProject,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
const user = useUserData();
|
||||
const isOwner = currentWorkspace.workspaceMembers.some(
|
||||
({ id, type }) => id === user?.id && type === 'owner',
|
||||
@@ -35,7 +38,7 @@ export default function ApplicationPaused() {
|
||||
const [showDeletingModal, setShowDeletingModal] = useState(false);
|
||||
const [unpauseApplication, { loading: changingApplicationStateLoading }] =
|
||||
useUnpauseApplicationMutation({
|
||||
refetchQueries: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
|
||||
const { data, loading } = useGetFreeAndActiveProjectsQuery({
|
||||
@@ -70,6 +73,8 @@ export default function ApplicationPaused() {
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
await refetchWorkspaceAndProject();
|
||||
} catch {
|
||||
// Note: The toast will handle the error.
|
||||
}
|
||||
@@ -118,14 +123,10 @@ export default function ApplicationPaused() {
|
||||
<Button
|
||||
className="mx-auto w-full max-w-[280px]"
|
||||
onClick={() => {
|
||||
openAlertDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
payload: <ChangePlanModal />,
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
maxWidth: 'lg',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import {
|
||||
refetchGetApplicationPlanQuery,
|
||||
useGetAppPlanAndGlobalPlansQuery,
|
||||
useGetPaymentMethodsQuery,
|
||||
useUpdateAppMutation,
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Checkbox from '@/ui/v2/Checkbox';
|
||||
import { BaseDialog } from '@/ui/v2/Dialog';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { planDescriptions } from '@/utils/planDescriptions';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useTheme } from '@mui/material';
|
||||
import getServerError from '@/utils/settings/getServerError/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
function Plan({
|
||||
planName,
|
||||
@@ -66,13 +66,15 @@ function Plan({
|
||||
}
|
||||
|
||||
export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
const theme = useTheme();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState('');
|
||||
const { closeAlertDialog } = useDialog();
|
||||
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const {
|
||||
currentWorkspace,
|
||||
currentProject,
|
||||
refetch: refetchWorkspaceAndProject,
|
||||
} = useCurrentWorkspaceAndProject();
|
||||
|
||||
// get workspace payment methods
|
||||
const { data } = useGetPaymentMethodsQuery({
|
||||
variables: {
|
||||
workspaceId: currentWorkspace?.id,
|
||||
@@ -80,7 +82,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
skip: !currentWorkspace,
|
||||
});
|
||||
|
||||
const { openPaymentModal, closePaymentModal, paymentModal } = useUI();
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const paymentMethodAvailable = data?.paymentMethods.length > 0;
|
||||
|
||||
const currentPlan = plans.find((plan) => plan.id === app.plan.id);
|
||||
@@ -88,8 +90,7 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
|
||||
const isDowngrade = currentPlan.price > selectedPlan?.price;
|
||||
|
||||
// graphql mutations
|
||||
const [updateApp] = useUpdateAppMutation({
|
||||
const [updateApp] = useUpdateApplicationMutation({
|
||||
refetchQueries: [
|
||||
refetchGetApplicationPlanQuery({
|
||||
workspace: currentWorkspace.slug,
|
||||
@@ -98,28 +99,35 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
],
|
||||
});
|
||||
|
||||
// function handlers
|
||||
const handleUpdateAppPlan = async () => {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: app.id,
|
||||
app: {
|
||||
planId: selectedPlan.id,
|
||||
try {
|
||||
await toast.promise(
|
||||
updateApp({
|
||||
variables: {
|
||||
appId: app.id,
|
||||
app: {
|
||||
planId: selectedPlan.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Updating plan...',
|
||||
success: `Plan has been updated successfully to ${selectedPlan.name}.`,
|
||||
error: getServerError(
|
||||
'An error occurred while updating the plan. Please try again.',
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
if (isDowngrade) {
|
||||
if (close) {
|
||||
close();
|
||||
}
|
||||
await refetchWorkspaceAndProject();
|
||||
|
||||
close?.();
|
||||
closeAlertDialog();
|
||||
setShowPaymentModal(false);
|
||||
} catch (error) {
|
||||
// Note: Error is handled by the toast.
|
||||
}
|
||||
|
||||
triggerToast(
|
||||
`${currentProject.name} plan changed to ${selectedPlan.name}.`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleChangePlanClick = async () => {
|
||||
@@ -128,33 +136,30 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
}
|
||||
|
||||
if (!paymentMethodAvailable) {
|
||||
openPaymentModal();
|
||||
setShowPaymentModal(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleUpdateAppPlan();
|
||||
|
||||
if (close) {
|
||||
close();
|
||||
}
|
||||
|
||||
setShowPaymentModal(false);
|
||||
close?.();
|
||||
closeAlertDialog();
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="w-full max-w-xl rounded-lg p-6 text-left">
|
||||
<Modal
|
||||
showModal={paymentModal}
|
||||
close={closePaymentModal}
|
||||
dialogStyle={{ zIndex: theme.zIndex.modal + 1 }}
|
||||
<BaseDialog
|
||||
open={showPaymentModal}
|
||||
onClose={() => setShowPaymentModal(false)}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
close={closePaymentModal}
|
||||
onPaymentMethodAdded={handleUpdateAppPlan}
|
||||
workspaceId={currentWorkspace.id}
|
||||
/>
|
||||
</Modal>
|
||||
</BaseDialog>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="mx-auto">
|
||||
<Image
|
||||
@@ -217,14 +222,12 @@ export function ChangePlanModalWithData({ app, plans, close }: any) {
|
||||
|
||||
export interface ChangePlanModalProps {
|
||||
/**
|
||||
* Function to close the modal if mounted on parent component.
|
||||
*
|
||||
* @deprecated Implement modal by using `openAlertDialog` hook instead.
|
||||
* Function to close the modal.
|
||||
*/
|
||||
close?: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function ChangePlanModal({ close }: ChangePlanModalProps) {
|
||||
export function ChangePlanModal({ onCancel }: ChangePlanModalProps) {
|
||||
const {
|
||||
query: { workspaceSlug, appSlug },
|
||||
} = useRouter();
|
||||
@@ -250,5 +253,5 @@ export function ChangePlanModal({ close }: ChangePlanModalProps) {
|
||||
const { apps, plans } = data;
|
||||
const app = apps[0];
|
||||
|
||||
return <ChangePlanModalWithData app={app} plans={plans} close={close} />;
|
||||
return <ChangePlanModalWithData app={app} plans={plans} close={onCancel} />;
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetOneUserDocument,
|
||||
useDeleteApplicationMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
@@ -47,7 +46,7 @@ export function RemoveApplicationModal({
|
||||
className,
|
||||
}: RemoveApplicationModalProps) {
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
refetchQueries: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
const [loadingRemove, setLoadingRemove] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
@@ -19,7 +19,7 @@ export function UnlockFeatureByUpgrading({
|
||||
className,
|
||||
...props
|
||||
}: UnlockFeatureByUpgradingProps) {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
return (
|
||||
<div className={twMerge('flex', className)} {...props}>
|
||||
@@ -29,14 +29,10 @@ export function UnlockFeatureByUpgrading({
|
||||
<Button
|
||||
variant="borderless"
|
||||
onClick={() => {
|
||||
openAlertDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
payload: <ChangePlanModal />,
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { EditRepositorySettingsFormData } from '@/components/applications/g
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
|
||||
import GithubIcon from '@/components/icons/GithubIcon';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUpdateApplicationMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
@@ -29,7 +29,7 @@ export function EditRepositorySettingsModal({
|
||||
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const [updateApp, { loading }] = useUpdateAppMutation();
|
||||
const [updateApp, { loading }] = useUpdateApplicationMutation();
|
||||
|
||||
const client = useApolloClient();
|
||||
|
||||
@@ -40,7 +40,7 @@ export function EditRepositorySettingsModal({
|
||||
if (!currentProject.githubRepository || selectedRepoId) {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentProject.id,
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
githubRepositoryId: selectedRepoId,
|
||||
repositoryProductionBranch: data.productionBranch,
|
||||
@@ -51,7 +51,7 @@ export function EditRepositorySettingsModal({
|
||||
} else {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentProject.id,
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
repositoryProductionBranch: data.productionBranch,
|
||||
nhostBaseFolder: data.repoBaseFolder,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Column } from 'react-table';
|
||||
import { expect, test } from 'vitest';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface OpenDialogOptions {
|
||||
/**
|
||||
* Title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* Component to render inside the dialog skeleton.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,13 @@ export function CountrySelector({ value, onChange }: CountrySelectorProps) {
|
||||
value={value || null}
|
||||
onChange={(_event, inputValue) => onChange(inputValue as string)}
|
||||
placeholder="Select Country"
|
||||
slotProps={{
|
||||
listbox: { className: 'min-w-0 w-full' },
|
||||
popper: {
|
||||
disablePortal: false,
|
||||
className: 'z-[10000] w-[270px] w-full',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{countries?.map((country) => (
|
||||
<Option key={country.name} value={country.code}>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import ColumnAutocomplete from './ColumnAutocomplete';
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import permissionVariablesQuery from '@/tests/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/tests/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/tests/msw/mocks/rest/tableQuery';
|
||||
import type { RuleGroup } from '@/types/dataBrowser';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import permissionVariablesQuery from '@/utils/msw/mocks/graphql/permissionVariablesQuery';
|
||||
import hasuraMetadataQuery from '@/utils/msw/mocks/rest/hasuraMetadataQuery';
|
||||
import tableQuery from '@/utils/msw/mocks/rest/tableQuery';
|
||||
import type { ComponentMeta, ComponentStory } from '@storybook/react';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Deployment } from '@/types/application';
|
||||
import { render, screen } from '@/utils/testUtils';
|
||||
import { test, vi } from 'vitest';
|
||||
import DeploymentStatusMessage from './DeploymentStatusMessage';
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import Form from '@/components/common/Form';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Input from '@/ui/v2/Input';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import {
|
||||
refetchGetOneUserQuery,
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useInsertWorkspaceMutation,
|
||||
useUpdateWorkspaceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { slugifyString } from '@/utils/helpers';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -85,11 +85,7 @@ export default function EditWorkspaceNameForm({
|
||||
const currentUser = useUserData();
|
||||
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
|
||||
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
|
||||
refetchQueries: [
|
||||
refetchGetOneUserQuery({
|
||||
userId: currentUser.id,
|
||||
}),
|
||||
],
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
awaitRefetchQueries: true,
|
||||
ignoreResults: true,
|
||||
});
|
||||
@@ -196,7 +192,7 @@ export default function EditWorkspaceNameForm({
|
||||
}
|
||||
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
|
||||
// The form has been submitted, it's not dirty anymore
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useGetWorkspaceMemberInvitesToManageQuery } from '@/generated/graphql';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetWorkspaceMemberInvitesToManageDocument,
|
||||
useGetWorkspaceMemberInvitesToManageQuery,
|
||||
} from '@/generated/graphql';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import Box from '@/ui/v2/Box';
|
||||
@@ -114,7 +118,10 @@ export function InviteAnnounce() {
|
||||
|
||||
// just refetch all data
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser', 'getWorkspaceMemberInvitesToManage'],
|
||||
include: [
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetWorkspaceMemberInvitesToManageDocument,
|
||||
],
|
||||
});
|
||||
|
||||
setIgnoreState({
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { AuthenticatedLayoutProps } from '@/components/layout/Authenticated
|
||||
import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import useProjectRoutes from '@/hooks/common/useProjectRoutes';
|
||||
import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWorkspacesAndApplications';
|
||||
import { useNavigationVisible } from '@/hooks/useNavigationVisible';
|
||||
import useNotFoundRedirect from '@/hooks/useNotFoundRedirect';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
@@ -47,7 +46,6 @@ function ProjectLayoutContent({
|
||||
),
|
||||
);
|
||||
|
||||
useGetAllUserWorkspacesAndApplications(false);
|
||||
useNotFoundRedirect();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { queryClient, render, screen } from '@/tests/testUtils';
|
||||
import type { Project } from '@/types/application';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { queryClient, render, screen } from '@/utils/testUtils';
|
||||
import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import OverviewDeployments from '.';
|
||||
import OverviewDeployments from './OverviewDeployments';
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function OverviewTopBar() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
const isPro = !currentProject?.plan?.isFree;
|
||||
const { openAlertDialog } = useDialog();
|
||||
const { openDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
if (!isPlatform) {
|
||||
@@ -92,14 +92,10 @@ export default function OverviewTopBar() {
|
||||
variant="borderless"
|
||||
className="mr-2"
|
||||
onClick={() => {
|
||||
openAlertDialog({
|
||||
title: 'Upgrade your plan.',
|
||||
payload: <ChangePlanModal />,
|
||||
openDialog({
|
||||
component: <ChangePlanModal />,
|
||||
props: {
|
||||
PaperProps: { className: 'p-0 max-w-xl w-full' },
|
||||
hidePrimaryAction: true,
|
||||
hideSecondaryAction: true,
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -39,12 +39,6 @@ export interface SettingsContainerProps
|
||||
* @default 'https://docs.nhost.io/'
|
||||
*/
|
||||
docsLink?: string;
|
||||
/**
|
||||
* Props for the primary action.
|
||||
*
|
||||
* @deprecated Use `slotProps.submitButton` instead.
|
||||
*/
|
||||
primaryActionButtonProps?: ButtonProps;
|
||||
/**
|
||||
* Submit button text.
|
||||
*
|
||||
@@ -106,7 +100,6 @@ export default function SettingsContainer({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
primaryActionButtonProps,
|
||||
submitButtonText = 'Save',
|
||||
className,
|
||||
onEnabledChange,
|
||||
@@ -188,18 +181,10 @@ export default function SettingsContainer({
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={
|
||||
(submitButton || primaryActionButtonProps)?.disabled
|
||||
? 'outlined'
|
||||
: 'contained'
|
||||
}
|
||||
color={
|
||||
(submitButton || primaryActionButtonProps)?.disabled
|
||||
? 'secondary'
|
||||
: 'primary'
|
||||
}
|
||||
variant={submitButton?.disabled ? 'outlined' : 'contained'}
|
||||
color={submitButton?.disabled ? 'secondary' : 'primary'}
|
||||
type="submit"
|
||||
{...(submitButton || primaryActionButtonProps)}
|
||||
{...submitButton}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
|
||||
@@ -128,6 +128,13 @@ export default function SettingsSidebar({
|
||||
>
|
||||
General
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/resources"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Compute Resources
|
||||
</SettingsNavLink>
|
||||
{isK8SPostgresEnabledInCurrentEnvironment && (
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
|
||||
@@ -2,7 +2,10 @@ import Form from '@/components/common/Form';
|
||||
import InlineCode from '@/components/common/InlineCode';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Input from '@/ui/v2/Input';
|
||||
@@ -24,7 +27,7 @@ export interface BaseDirectoryFormValues {
|
||||
export default function BaseDirectorySettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [updateApp] = useUpdateApplicationMutation();
|
||||
const client = useApolloClient();
|
||||
|
||||
const form = useForm<BaseDirectoryFormValues>({
|
||||
@@ -45,7 +48,7 @@ export default function BaseDirectorySettings() {
|
||||
const handleBaseFolderChange = async (values: BaseDirectoryFormValues) => {
|
||||
const updateAppMutation = updateApp({
|
||||
variables: {
|
||||
id: currentProject.id,
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
...values,
|
||||
},
|
||||
@@ -67,7 +70,9 @@ export default function BaseDirectorySettings() {
|
||||
form.reset(values);
|
||||
|
||||
try {
|
||||
await client.refetchQueries({ include: ['getOneUser'] });
|
||||
await client.refetchQueries({
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
error.message || 'Error while trying to update application cache',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useUpdateApplicationMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Input from '@/ui/v2/Input';
|
||||
@@ -23,7 +26,7 @@ export interface DeploymentBranchFormValues {
|
||||
export default function DeploymentBranchSettings() {
|
||||
const { maintenanceActive } = useUI();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [updateApp] = useUpdateApplicationMutation();
|
||||
const client = useApolloClient();
|
||||
|
||||
const form = useForm<DeploymentBranchFormValues>({
|
||||
@@ -46,7 +49,7 @@ export default function DeploymentBranchSettings() {
|
||||
) => {
|
||||
const updateAppMutation = updateApp({
|
||||
variables: {
|
||||
id: currentProject.id,
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
...values,
|
||||
},
|
||||
@@ -68,7 +71,9 @@ export default function DeploymentBranchSettings() {
|
||||
form.reset(values);
|
||||
|
||||
try {
|
||||
await client.refetchQueries({ include: ['getOneUser'] });
|
||||
await client.refetchQueries({
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
error.message || 'Error while trying to update application cache',
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import useProPlan from '@/hooks/common/useProPlan';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
|
||||
export interface ResourcesConfirmationDialogProps {
|
||||
/**
|
||||
* Price of the new plan.
|
||||
*/
|
||||
updatedResources: {
|
||||
vcpu: number;
|
||||
memory: number;
|
||||
};
|
||||
/**
|
||||
* Function to be called when the user clicks the cancel button.
|
||||
*/
|
||||
onCancel: () => void;
|
||||
/**
|
||||
* Function to be called when the user clicks the confirm button.
|
||||
*/
|
||||
onSubmit: () => Promise<void>;
|
||||
}
|
||||
|
||||
export default function ResourcesConfirmationDialog({
|
||||
updatedResources,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}: ResourcesConfirmationDialogProps) {
|
||||
const { data: proPlan, loading, error } = useProPlan();
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE * (updatedResources.vcpu / RESOURCE_VCPU_MULTIPLIER);
|
||||
|
||||
if (!loading && !proPlan) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this project. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-flow-row gap-6 px-6 pb-6">
|
||||
{updatedResources.vcpu > 0 ? (
|
||||
<Text className="text-center">
|
||||
Please allow some time for the selected resources to take effect.
|
||||
</Text>
|
||||
) : (
|
||||
<Text className="text-center">
|
||||
By confirming this you will go back to the original amount of
|
||||
resources of the {proPlan.name} plan.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="grid grid-flow-row gap-4">
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="font-medium">{proPlan.name} Plan</Text>
|
||||
<Text>${proPlan.price.toFixed(2)}/mo</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Box className="grid grid-flow-row gap-0.5">
|
||||
<Text className="font-medium">Dedicated Resources</Text>
|
||||
<Text className="text-xs" color="secondary">
|
||||
{prettifyVCPU(updatedResources.vcpu)} vCPUs +{' '}
|
||||
{prettifyMemory(updatedResources.memory)} of Memory
|
||||
</Text>
|
||||
</Box>
|
||||
<Text>${updatedPrice.toFixed(2)}/mo</Text>
|
||||
</Box>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Box className="grid grid-flow-col justify-between gap-2">
|
||||
<Text className="font-medium">Total</Text>
|
||||
<Text>${(updatedPrice + proPlan.price).toFixed(2)}/mo</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color={updatedResources.vcpu > 0 ? 'primary' : 'error'}
|
||||
onClick={onSubmit}
|
||||
autoFocus
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
<Button variant="borderless" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ResourcesConfirmationDialog';
|
||||
export { default } from './ResourcesConfirmationDialog';
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
getProPlanOnlyQuery,
|
||||
getWorkspaceAndProjectQuery,
|
||||
} from '@/tests/msw/mocks/graphql/plansQuery';
|
||||
import {
|
||||
resourcesAvailableQuery,
|
||||
resourcesUnavailableQuery,
|
||||
resourcesUpdatedQuery,
|
||||
} from '@/tests/msw/mocks/graphql/resourceSettingsQuery';
|
||||
import updateConfigMutation from '@/tests/msw/mocks/graphql/updateConfigMutation';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
waitForElementToBeRemoved,
|
||||
within,
|
||||
} from '@/tests/testUtils';
|
||||
import {
|
||||
RESOURCE_MEMORY_MULTIPLIER,
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { test, vi } from 'vitest';
|
||||
import ResourcesForm from './ResourcesForm';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
basePath: '',
|
||||
pathname: '/test-workspace/test-application',
|
||||
route: '/[workspaceSlug]/[appSlug]',
|
||||
asPath: '/test-workspace/test-application',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {
|
||||
workspaceSlug: 'test-workspace',
|
||||
appSlug: 'test-application',
|
||||
},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
back: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
beforePopState: vi.fn(),
|
||||
events: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
isFallback: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
const server = setupServer(
|
||||
resourcesAvailableQuery,
|
||||
getProPlanOnlyQuery,
|
||||
getWorkspaceAndProjectQuery,
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
afterEach(() => server.resetHandlers());
|
||||
afterAll(() => server.close());
|
||||
|
||||
// Note: Workaround based on https://github.com/testing-library/user-event/issues/871#issuecomment-1059317998
|
||||
function changeSliderValue(slider: HTMLElement, value: number) {
|
||||
fireEvent.input(slider, { target: { value } });
|
||||
fireEvent.change(slider, { target: { value } });
|
||||
}
|
||||
|
||||
test('should show an empty state message that the feature must be enabled if no data is available', async () => {
|
||||
server.use(resourcesUnavailableQuery);
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show the sliders if the switch is enabled', async () => {
|
||||
server.use(resourcesUnavailableQuery);
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(9);
|
||||
});
|
||||
|
||||
test('should not show an empty state message if there is data available', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.queryByText(/enable this feature/i)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('slider')).toHaveLength(9);
|
||||
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 8/i);
|
||||
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 16384 mib/i);
|
||||
});
|
||||
|
||||
test('should show a warning message if not all the resources are allocated', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/^vcpus:/i)).toHaveTextContent(/vcpus: 9/i);
|
||||
expect(screen.getByText(/^memory:/i)).toHaveTextContent(/memory: 18432 mib/i);
|
||||
|
||||
expect(
|
||||
screen.getByText(/you now have 1 vcpus and 2048 mib of memory unused./i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should update the price when the top slider is changed', async () => {
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.queryByText(/\$200\.00\/mo/i)).not.toBeInTheDocument();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/\$425\.00\/mo/i)).toBeInTheDocument();
|
||||
// we display the final price in two places
|
||||
expect(screen.getAllByText(/\$475\.00\/mo/i)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should show a validation error when the form is submitted when not everything is allocated', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(
|
||||
screen.getAllByText(/you now have 1 vcpus and 2048 mib of memory unused./i),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should show a confirmation dialog when the form is submitted', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('slider', { name: /total available vcpu/i }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', {
|
||||
name: /total available vcpu/i,
|
||||
}),
|
||||
9 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage vcpu/i }),
|
||||
2.25 * RESOURCE_VCPU_MULTIPLIER,
|
||||
);
|
||||
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /database memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /hasura graphql memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /auth memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
changeSliderValue(
|
||||
screen.getByRole('slider', { name: /storage memory/i }),
|
||||
4.5 * RESOURCE_MEMORY_MULTIPLIER,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByRole('heading', {
|
||||
name: /confirm dedicated resources/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(
|
||||
/9 vcpus \+ 18432 mib of memory/i,
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(screen.getByRole('dialog')).getByText(/\$475\.00\/mo/i, {
|
||||
exact: true,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// we need to mock the query again because the mutation updated the resources
|
||||
// and we need to return the updated values
|
||||
server.use(resourcesUpdatedQuery);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('dialog'));
|
||||
expect(
|
||||
await screen.findByText(/resources have been updated successfully./i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('slider', { name: /total available vcpu/i }),
|
||||
).toHaveValue((9 * RESOURCE_VCPU_MULTIPLIER).toString());
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should display a red button when custom resources are disabled', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(screen.getByText(/enable this feature/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/total cost:/i)).toHaveTextContent(
|
||||
/total cost: \$25\.00\/mo/i,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByRole('heading', { name: /disable dedicated resources/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /confirm/i })).toHaveStyle({
|
||||
'background-color': '#f13154',
|
||||
});
|
||||
});
|
||||
|
||||
test('should hide the footer when custom resource allocation is disabled', async () => {
|
||||
server.use(updateConfigMutation);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<ResourcesForm />);
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('progressbar'));
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
await user.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
|
||||
server.use(resourcesUnavailableQuery);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm/i }));
|
||||
|
||||
await waitForElementToBeRemoved(() => screen.getByRole('dialog'));
|
||||
|
||||
expect(screen.queryByText(/total cost:/i)).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import Form from '@/components/common/Form';
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import ResourcesConfirmationDialog from '@/components/settings/resources/ResourcesConfirmationDialog';
|
||||
import ServiceResourcesFormFragment from '@/components/settings/resources/ServiceResourcesFormFragment';
|
||||
import TotalResourcesFormFragment from '@/components/settings/resources/TotalResourcesFormFragment';
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import { resourceSettingsValidationSchema } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import useProPlan from '@/hooks/common/useProPlan';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import {
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import type { GetResourcesQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetResourcesDocument,
|
||||
useGetResourcesQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import getUnallocatedResources from '@/utils/settings/getUnallocatedResources';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { cpu, memory } = data?.config?.[service]?.resources?.compute || {};
|
||||
|
||||
return {
|
||||
vcpu: cpu || 0,
|
||||
memory: memory || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ResourcesForm() {
|
||||
const [validationError, setValidationError] = useState<Error | null>(null);
|
||||
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error: resourcesError,
|
||||
} = useGetResourcesQuery({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: proPlan,
|
||||
loading: proPlanLoading,
|
||||
error: proPlanError,
|
||||
} = useProPlan();
|
||||
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
refetchQueries: [GetResourcesDocument],
|
||||
});
|
||||
|
||||
const initialDatabaseResources = getInitialServiceResources(data, 'postgres');
|
||||
const initialHasuraResources = getInitialServiceResources(data, 'hasura');
|
||||
const initialAuthResources = getInitialServiceResources(data, 'auth');
|
||||
const initialStorageResources = getInitialServiceResources(data, 'storage');
|
||||
|
||||
const totalInitialVCPU =
|
||||
initialDatabaseResources.vcpu +
|
||||
initialHasuraResources.vcpu +
|
||||
initialAuthResources.vcpu +
|
||||
initialStorageResources.vcpu;
|
||||
|
||||
const totalInitialMemory =
|
||||
initialDatabaseResources.memory +
|
||||
initialHasuraResources.memory +
|
||||
initialAuthResources.memory +
|
||||
initialStorageResources.memory;
|
||||
|
||||
const form = useForm<ResourceSettingsFormValues>({
|
||||
values: {
|
||||
enabled: totalInitialVCPU > 0 && totalInitialMemory > 0,
|
||||
totalAvailableVCPU: totalInitialVCPU || 2000,
|
||||
totalAvailableMemory: totalInitialMemory || 4096,
|
||||
hasuraVCPU: initialHasuraResources.vcpu || 500,
|
||||
hasuraMemory: initialHasuraResources.memory || 1536,
|
||||
databaseVCPU: initialDatabaseResources.vcpu || 1000,
|
||||
databaseMemory: initialDatabaseResources.memory || 2048,
|
||||
authVCPU: initialAuthResources.vcpu || 250,
|
||||
authMemory: initialAuthResources.memory || 256,
|
||||
storageVCPU: initialStorageResources.vcpu || 250,
|
||||
storageMemory: initialStorageResources.memory || 256,
|
||||
},
|
||||
resolver: yupResolver(resourceSettingsValidationSchema),
|
||||
});
|
||||
|
||||
if (!proPlan && !proPlanLoading) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this project. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading || proPlanLoading) {
|
||||
return (
|
||||
<ActivityIndicator
|
||||
label="Loading resource settings..."
|
||||
delay={1000}
|
||||
className="mx-auto"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { watch, formState } = form;
|
||||
const isDirty = Object.keys(formState.dirtyFields).length > 0;
|
||||
|
||||
const enabled = watch('enabled');
|
||||
const totalAvailableVCPU = enabled ? watch('totalAvailableVCPU') : 0;
|
||||
|
||||
const initialPrice =
|
||||
RESOURCE_VCPU_PRICE * (totalInitialVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE * (totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
|
||||
async function handleSubmit(formValues: ResourceSettingsFormValues) {
|
||||
const updateConfigPromise = updateConfig({
|
||||
variables: {
|
||||
appId: currentProject?.id,
|
||||
config: {
|
||||
postgres: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.databaseVCPU,
|
||||
memory: formValues.databaseMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
hasura: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.hasuraVCPU,
|
||||
memory: formValues.hasuraMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
auth: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.authVCPU,
|
||||
memory: formValues.authMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
storage: {
|
||||
resources: enabled
|
||||
? {
|
||||
compute: {
|
||||
cpu: formValues.storageVCPU,
|
||||
memory: formValues.storageMemory,
|
||||
},
|
||||
replicas: 1,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
await toast.promise(
|
||||
updateConfigPromise,
|
||||
{
|
||||
loading: 'Updating resources...',
|
||||
success: 'Resources have been updated successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while updating resources. Please try again.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
if (!formValues.enabled) {
|
||||
form.reset({
|
||||
enabled: false,
|
||||
totalAvailableVCPU: 2000,
|
||||
totalAvailableMemory: 4096,
|
||||
hasuraVCPU: 500,
|
||||
hasuraMemory: 1536,
|
||||
databaseVCPU: 1000,
|
||||
databaseMemory: 2048,
|
||||
authVCPU: 250,
|
||||
authMemory: 256,
|
||||
storageVCPU: 250,
|
||||
storageMemory: 256,
|
||||
});
|
||||
} else {
|
||||
form.reset(null, { keepValues: true, keepDirty: false });
|
||||
}
|
||||
} catch {
|
||||
// Note: The error has already been handled by the toast.
|
||||
}
|
||||
}
|
||||
|
||||
function handleConfirm(formValues: ResourceSettingsFormValues) {
|
||||
setValidationError(null);
|
||||
|
||||
const { vcpu: unallocatedVCPU, memory: unallocatedMemory } =
|
||||
getUnallocatedResources(formValues);
|
||||
const hasUnusedResources = unallocatedVCPU > 0 || unallocatedMemory > 0;
|
||||
|
||||
if (hasUnusedResources) {
|
||||
const unusedResourceMessage = [
|
||||
unallocatedVCPU > 0 ? `${prettifyVCPU(unallocatedVCPU)} vCPUs` : '',
|
||||
unallocatedMemory > 0
|
||||
? `${prettifyMemory(unallocatedMemory)} of Memory`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
setValidationError(
|
||||
new Error(
|
||||
`You now have ${unusedResourceMessage} unused. Allocate it to any of the services before saving.`,
|
||||
),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openDialog({
|
||||
title: enabled
|
||||
? 'Confirm Dedicated Resources'
|
||||
: 'Disable Dedicated Resources',
|
||||
component: (
|
||||
<ResourcesConfirmationDialog
|
||||
updatedResources={{
|
||||
vcpu: enabled ? formValues.totalAvailableVCPU : 0,
|
||||
memory: enabled ? formValues.totalAvailableMemory : 0,
|
||||
}}
|
||||
onCancel={closeDialog}
|
||||
onSubmit={async () => {
|
||||
await handleSubmit(formValues);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
props: {
|
||||
titleProps: { className: 'justify-center pb-1' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (resourcesError || proPlanError) {
|
||||
throw resourcesError || proPlanError;
|
||||
}
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<Form onSubmit={handleConfirm}>
|
||||
<SettingsContainer
|
||||
title="Compute Resources"
|
||||
description="See how much compute you have available and customise allocation on this page."
|
||||
className="gap-0 px-0"
|
||||
showSwitch
|
||||
switchId="enabled"
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: !enabled || !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
// Note: We need a custom footer because of the pricing
|
||||
// information
|
||||
footer: { className: 'hidden', 'aria-hidden': true },
|
||||
}}
|
||||
>
|
||||
{enabled ? (
|
||||
<>
|
||||
<TotalResourcesFormFragment initialPrice={initialPrice} />
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="PostgreSQL Database"
|
||||
description="Manage how much compute you need for the PostgreSQL Database."
|
||||
cpuKey="databaseVCPU"
|
||||
memoryKey="databaseMemory"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Hasura GraphQL"
|
||||
description="Manage how much compute you need for the Hasura GraphQL API."
|
||||
cpuKey="hasuraVCPU"
|
||||
memoryKey="hasuraMemory"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Auth"
|
||||
description="Manage how much compute you need for Auth."
|
||||
cpuKey="authVCPU"
|
||||
memoryKey="authMemory"
|
||||
/>
|
||||
<Divider />
|
||||
<ServiceResourcesFormFragment
|
||||
title="Storage"
|
||||
description="Manage how much compute you need for Storage."
|
||||
cpuKey="storageVCPU"
|
||||
memoryKey="storageMemory"
|
||||
/>
|
||||
{validationError && (
|
||||
<Box className="px-4 pb-4">
|
||||
<Alert
|
||||
severity="error"
|
||||
className="flex flex-col gap-2 text-left"
|
||||
>
|
||||
<strong>
|
||||
Please use all the available vCPUs and Memory
|
||||
</strong>
|
||||
|
||||
<p>{validationError.message}</p>
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box className={twMerge('px-4', (enabled || isDirty) && 'pb-4')}>
|
||||
<Alert className="text-left">
|
||||
Enable this feature to access custom resource allocation for
|
||||
your services.
|
||||
</Alert>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(enabled || isDirty) && (
|
||||
<Box className="flex flex-row items-center justify-between border-t px-4 pt-4">
|
||||
<span />
|
||||
|
||||
<Box className="flex flex-row items-center gap-4">
|
||||
<Text>
|
||||
Total cost:{' '}
|
||||
<span className="font-medium">
|
||||
${updatedPrice.toFixed(2)}/mo
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant={isDirty ? 'contained' : 'outlined'}
|
||||
color={isDirty ? 'primary' : 'secondary'}
|
||||
disabled={!isDirty}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ResourcesForm';
|
||||
@@ -0,0 +1,172 @@
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import {
|
||||
MAX_SERVICE_MEMORY,
|
||||
MAX_SERVICE_VCPU,
|
||||
MIN_SERVICE_MEMORY,
|
||||
MIN_SERVICE_VCPU,
|
||||
} from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Slider from '@/ui/v2/Slider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { RESOURCE_MEMORY_STEP, RESOURCE_VCPU_STEP } from '@/utils/CONSTANTS';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export interface ServiceResourcesFormFragmentProps {
|
||||
/**
|
||||
* The title of the form fragment.
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* The description of the form fragment.
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Form field name for CPU.
|
||||
*/
|
||||
cpuKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
/**
|
||||
* Form field name for Memory.
|
||||
*/
|
||||
memoryKey: Exclude<
|
||||
keyof ResourceSettingsFormValues,
|
||||
'enabled' | 'totalAvailableVCPU' | 'totalAvailableMemory'
|
||||
>;
|
||||
}
|
||||
|
||||
export default function ServiceResourcesFormFragment({
|
||||
title,
|
||||
description,
|
||||
cpuKey,
|
||||
memoryKey,
|
||||
}: ServiceResourcesFormFragmentProps) {
|
||||
const { setValue } = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
|
||||
// Total allocated CPU for all resources
|
||||
const totalAllocatedCPU = Object.keys(formValues)
|
||||
.filter((key) => key.endsWith('CPU') && key !== 'totalAvailableVCPU')
|
||||
.reduce((acc, key) => acc + formValues[key], 0);
|
||||
|
||||
// Total allocated memory for all resources
|
||||
const totalAllocatedMemory = Object.keys(formValues)
|
||||
.filter((key) => key.endsWith('Memory') && key !== 'totalAvailableMemory')
|
||||
.reduce((acc, key) => acc + formValues[key], 0);
|
||||
|
||||
const remainingCPU = formValues.totalAvailableVCPU - totalAllocatedCPU;
|
||||
const allowedCPU = remainingCPU + formValues[cpuKey];
|
||||
|
||||
const remainingMemory =
|
||||
formValues.totalAvailableMemory - totalAllocatedMemory;
|
||||
const allowedMemory = remainingMemory + formValues[memoryKey];
|
||||
|
||||
function handleCPUChange(value: string) {
|
||||
const updatedCPU = parseFloat(value);
|
||||
const exceedsAvailableCPU =
|
||||
updatedCPU + (totalAllocatedCPU - formValues[cpuKey]) >
|
||||
formValues.totalAvailableVCPU;
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedCPU) ||
|
||||
exceedsAvailableCPU ||
|
||||
updatedCPU < MIN_SERVICE_VCPU
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(cpuKey, updatedCPU, { shouldDirty: true });
|
||||
}
|
||||
|
||||
function handleMemoryChange(value: string) {
|
||||
const updatedMemory = parseFloat(value);
|
||||
const exceedsAvailableMemory =
|
||||
updatedMemory + (totalAllocatedMemory - formValues[memoryKey]) >
|
||||
formValues.totalAvailableMemory;
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedMemory) ||
|
||||
exceedsAvailableMemory ||
|
||||
updatedMemory < MIN_SERVICE_MEMORY
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue(memoryKey, updatedMemory, { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="grid grid-flow-row gap-4 p-4">
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Text variant="h3" className="font-semibold">
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
<Text color="secondary">{description}</Text>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Text>
|
||||
Allocated vCPUs:{' '}
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(formValues[cpuKey])}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingCPU > 0 && formValues[cpuKey] < MAX_SERVICE_VCPU && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyVCPU(remainingCPU)} vCPUs
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={formValues[cpuKey]}
|
||||
onChange={(_event, value) => handleCPUChange(value.toString())}
|
||||
max={MAX_SERVICE_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
allowed={allowedCPU}
|
||||
aria-label={`${title} vCPU`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Box className="grid grid-flow-col items-center justify-between gap-2">
|
||||
<Text>
|
||||
Allocated Memory:{' '}
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(formValues[memoryKey])}
|
||||
</span>
|
||||
</Text>
|
||||
|
||||
{remainingMemory > 0 && formValues[memoryKey] < MAX_SERVICE_MEMORY && (
|
||||
<Text className="text-sm">
|
||||
<span className="font-medium">
|
||||
{prettifyMemory(remainingMemory)} of Memory
|
||||
</span>{' '}
|
||||
remaining
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Slider
|
||||
value={formValues[memoryKey]}
|
||||
onChange={(_event, value) => handleMemoryChange(value.toString())}
|
||||
max={MAX_SERVICE_MEMORY}
|
||||
step={RESOURCE_MEMORY_STEP}
|
||||
allowed={allowedMemory}
|
||||
aria-label={`${title} Memory`}
|
||||
marks
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './ServiceResourcesFormFragment';
|
||||
export { default } from './ServiceResourcesFormFragment';
|
||||
@@ -0,0 +1,184 @@
|
||||
import { prettifyMemory } from '@/features/settings/resources/utils/prettifyMemory';
|
||||
import { prettifyVCPU } from '@/features/settings/resources/utils/prettifyVCPU';
|
||||
import type { ResourceSettingsFormValues } from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import {
|
||||
MAX_TOTAL_VCPU,
|
||||
MIN_TOTAL_MEMORY,
|
||||
MIN_TOTAL_VCPU,
|
||||
} from '@/features/settings/resources/utils/resourceSettingsValidationSchema';
|
||||
import useProPlan from '@/hooks/common/useProPlan';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Slider, { sliderClasses } from '@/ui/v2/Slider';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import ArrowRightIcon from '@/ui/v2/icons/ArrowRightIcon';
|
||||
import {
|
||||
RESOURCE_MEMORY_MULTIPLIER,
|
||||
RESOURCE_VCPU_MEMORY_RATIO,
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
RESOURCE_VCPU_PRICE,
|
||||
RESOURCE_VCPU_STEP,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import getUnallocatedResources from '@/utils/settings/getUnallocatedResources';
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
export interface TotalResourcesFormFragmentProps {
|
||||
/**
|
||||
* The initial price of the resources.
|
||||
*/
|
||||
initialPrice: number;
|
||||
}
|
||||
|
||||
const StyledAvailableCpuSlider = styled(Slider)(({ theme }) => ({
|
||||
[`& .${sliderClasses.rail}`]: {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
}));
|
||||
|
||||
export default function TotalResourcesFormFragment({
|
||||
initialPrice,
|
||||
}: TotalResourcesFormFragmentProps) {
|
||||
const {
|
||||
data: proPlan,
|
||||
error: proPlanError,
|
||||
loading: proPlanLoading,
|
||||
} = useProPlan();
|
||||
const { setValue } = useFormContext<ResourceSettingsFormValues>();
|
||||
const formValues = useWatch<ResourceSettingsFormValues>();
|
||||
|
||||
if (!proPlan && !proPlanLoading) {
|
||||
return (
|
||||
<Alert severity="error">
|
||||
Couldn't load the plan for this projectee. Please try again.
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (proPlanError) {
|
||||
throw proPlanError;
|
||||
}
|
||||
|
||||
const allocatedCPU =
|
||||
formValues.databaseVCPU +
|
||||
formValues.hasuraVCPU +
|
||||
formValues.authVCPU +
|
||||
formValues.storageVCPU;
|
||||
const allocatedMemory =
|
||||
formValues.databaseMemory +
|
||||
formValues.hasuraMemory +
|
||||
formValues.authMemory +
|
||||
formValues.storageMemory;
|
||||
|
||||
const updatedPrice =
|
||||
RESOURCE_VCPU_PRICE *
|
||||
(formValues.totalAvailableVCPU / RESOURCE_VCPU_MULTIPLIER) +
|
||||
proPlan.price;
|
||||
|
||||
const { vcpu: unallocatedVCPU, memory: unallocatedMemory } =
|
||||
getUnallocatedResources(formValues);
|
||||
|
||||
const hasUnusedResources = unallocatedVCPU > 0 || unallocatedMemory > 0;
|
||||
const unusedResourceMessage = [
|
||||
unallocatedVCPU > 0 ? `${prettifyVCPU(unallocatedVCPU)} vCPUs` : '',
|
||||
unallocatedMemory > 0
|
||||
? `${prettifyMemory(unallocatedMemory)} of Memory`
|
||||
: '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' and ');
|
||||
|
||||
function handleCPUChange(value: string) {
|
||||
const updatedCPU = parseFloat(value);
|
||||
const updatedMemory =
|
||||
(updatedCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
if (
|
||||
Number.isNaN(updatedCPU) ||
|
||||
updatedCPU < Math.max(MIN_TOTAL_VCPU, allocatedCPU) ||
|
||||
updatedMemory < Math.max(MIN_TOTAL_MEMORY, allocatedMemory)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('totalAvailableVCPU', updatedCPU, { shouldDirty: true });
|
||||
setValue('totalAvailableMemory', updatedMemory, { shouldDirty: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="px-4 pb-4">
|
||||
<Box className="rounded-md border">
|
||||
<Box className="flex flex-col gap-4 bg-transparent p-4">
|
||||
<Box className="flex flex-row items-center justify-between gap-4">
|
||||
<Text color="secondary">
|
||||
Total available compute for your project:
|
||||
</Text>
|
||||
|
||||
{initialPrice !== updatedPrice && (
|
||||
<Text className="flex flex-row items-center justify-end gap-2">
|
||||
<Text component="span" color="secondary">
|
||||
${initialPrice.toFixed(2)}/mo
|
||||
</Text>
|
||||
<ArrowRightIcon />
|
||||
<Text component="span" className="font-medium">
|
||||
${updatedPrice.toFixed(2)}/mo
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className="flex flex-row items-center justify-start gap-4">
|
||||
<Text color="secondary">
|
||||
vCPUs:{' '}
|
||||
<Text component="span" color="primary" className="font-medium">
|
||||
{prettifyVCPU(formValues.totalAvailableVCPU)}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
<Text color="secondary">
|
||||
Memory:{' '}
|
||||
<Text component="span" color="primary" className="font-medium">
|
||||
{prettifyMemory(formValues.totalAvailableMemory)}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<StyledAvailableCpuSlider
|
||||
value={formValues.totalAvailableVCPU}
|
||||
onChange={(_event, value) => handleCPUChange(value.toString())}
|
||||
max={MAX_TOTAL_VCPU}
|
||||
step={RESOURCE_VCPU_STEP}
|
||||
aria-label="Total Available vCPU"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Alert
|
||||
severity={hasUnusedResources ? 'warning' : 'info'}
|
||||
className="grid grid-flow-row gap-2 rounded-t-none rounded-b-[5px] text-left"
|
||||
>
|
||||
{hasUnusedResources ? (
|
||||
<>
|
||||
<strong>Please use all the available vCPUs and Memory</strong>
|
||||
|
||||
<p>
|
||||
You now have {unusedResourceMessage} unused. Allocate it to any
|
||||
of the services before saving.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>You're All Set</strong>
|
||||
|
||||
<p>
|
||||
You have successfully allocated all the available vCPUs and
|
||||
Memory.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './TotalResourcesFormFragment';
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Link from '@/ui/v2/Link';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import ArrowSquareOutIcon from '@/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { useConfirmProvidersUpdatedMutation } from '@/utils/__generated__/graphql';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import { useState } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function ProvidersUpdatedAlert() {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [confirmed, setConfirmed] = useState(true);
|
||||
|
||||
const [confirmProvidersUpdated] = useConfirmProvidersUpdatedMutation({
|
||||
variables: { id: currentProject?.id },
|
||||
});
|
||||
|
||||
async function handleSubmitConfirmation() {
|
||||
const confirmProvidersUpdatedPromise = confirmProvidersUpdated();
|
||||
|
||||
await toast.promise(
|
||||
confirmProvidersUpdatedPromise,
|
||||
{
|
||||
loading: 'Confirming...',
|
||||
success: 'Your settings have been updated successfully.',
|
||||
error: getServerError(
|
||||
'An error occurred while trying to confirm the message.',
|
||||
),
|
||||
},
|
||||
getToastStyleProps(),
|
||||
);
|
||||
|
||||
setConfirmed(false);
|
||||
}
|
||||
|
||||
function handleOpenConfirmationDialog() {
|
||||
openAlertDialog({
|
||||
title: 'Confirm all providers updated?',
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Please make sure to update all providers before continuing. Your
|
||||
sign-in flows might break if you don't.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
onPrimaryAction: handleSubmitConfirmation,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!confirmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert className="grid grid-flow-row place-items-center items-center gap-2 bg-amber-500 p-4 lg:grid-flow-col lg:place-content-between">
|
||||
<div className="grid grid-flow-row gap-1 text-left">
|
||||
<Text className="font-semibold">
|
||||
Please update the Redirect URL for all providers being used
|
||||
</Text>
|
||||
|
||||
<Text className="text-sm+">
|
||||
We are deprecating your project's old DNS name in favor of
|
||||
individual DNS names for each service. Please make sure to update your
|
||||
providers to use the new auth specific URL under <b>Redirect URL</b>{' '}
|
||||
before the 1st of February 2023.{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/discussions/1319"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
Read the discussion here.
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button variant="borderless" onClick={handleOpenConfirmationDialog}>
|
||||
I have updated all Redirect URLs
|
||||
</Button>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default } from './ProvidersUpdatedAlert';
|
||||
@@ -21,7 +21,7 @@ export function Alert({
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'rounded-sm+ bg-opacity-20 p-2 text-center text-sm+',
|
||||
'rounded-sm+ bg-opacity-20 p-4 text-center text-sm+ motion-safe:transition-colors',
|
||||
className,
|
||||
)}
|
||||
sx={[
|
||||
@@ -32,11 +32,11 @@ export function Alert({
|
||||
},
|
||||
severity === 'warning' && {
|
||||
backgroundColor: 'warning.light',
|
||||
color: 'warning.dark',
|
||||
color: 'text.primary',
|
||||
},
|
||||
severity === 'success' && {
|
||||
backgroundColor: 'success.light',
|
||||
color: 'success.main',
|
||||
color: 'success.dark',
|
||||
},
|
||||
severity === 'info' && { backgroundColor: 'primary.light' },
|
||||
]}
|
||||
|
||||
@@ -42,7 +42,7 @@ function AlertDialog({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{!hideTitle && (
|
||||
{!hideTitle && !!title && (
|
||||
<Dialog.Title {...titleProps} id="alert-dialog-title">
|
||||
{title}
|
||||
</Dialog.Title>
|
||||
|
||||
@@ -141,6 +141,7 @@ const ContainedButton = forwardRef(
|
||||
backgroundColor: 'error.dark',
|
||||
},
|
||||
'&:focus': {
|
||||
backgroundColor: 'error.main',
|
||||
boxShadow: (theme) =>
|
||||
`0 0 0 2px ${alpha(theme.palette.error.main, 0.3)}`,
|
||||
},
|
||||
|
||||
@@ -22,7 +22,7 @@ function Dialog({
|
||||
aria-describedby="dialog-description"
|
||||
{...props}
|
||||
>
|
||||
{!hideTitle && (
|
||||
{!hideTitle && !!title && (
|
||||
<DialogTitle
|
||||
sx={{
|
||||
padding: (theme) => theme.spacing(3, 3, 1.5, 3),
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface CommonDialogProps
|
||||
/**
|
||||
* The title of the dialog.
|
||||
*/
|
||||
title: ReactNode;
|
||||
title?: ReactNode;
|
||||
/**
|
||||
* The message to display in the dialog.
|
||||
*/
|
||||
|
||||
81
dashboard/src/components/ui/v2/Slider/Slider.tsx
Normal file
81
dashboard/src/components/ui/v2/Slider/Slider.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
|
||||
import MaterialSlider, {
|
||||
sliderClasses as materialSliderClasses,
|
||||
} from '@mui/material/Slider';
|
||||
import type { ForwardedRef, PropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import SliderRail from './SliderRail';
|
||||
|
||||
export interface SliderProps
|
||||
extends PropsWithoutRef<Omit<MaterialSliderProps, 'color'>> {
|
||||
/**
|
||||
* The maximum allowed value of the slider. The rail will be colored up to
|
||||
* this value.
|
||||
*/
|
||||
allowed?: number;
|
||||
}
|
||||
|
||||
const StyledSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
color: theme.palette.primary.main,
|
||||
[`& .${materialSliderClasses.mark}`]: {
|
||||
height: 6,
|
||||
width: 1,
|
||||
backgroundColor: theme.palette.grey[400],
|
||||
},
|
||||
[`& .${materialSliderClasses.rail}`]: {
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
height: 6,
|
||||
},
|
||||
[`& .${materialSliderClasses.markActive}`]: {
|
||||
opacity: 0,
|
||||
},
|
||||
[`& .${materialSliderClasses.track}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
height: 6,
|
||||
},
|
||||
[`& .${materialSliderClasses.thumb}`]: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
'&:before': {
|
||||
boxShadow: 'none',
|
||||
},
|
||||
},
|
||||
[`& .${materialSliderClasses.thumbColorPrimary}`]: {
|
||||
backgroundColor: theme.palette.primary.main,
|
||||
[`&:focus, &:hover, &.${materialSliderClasses.active}, &.${materialSliderClasses.focusVisible}`]:
|
||||
{
|
||||
boxShadow: `0 0 0 2px ${alpha(theme.palette.primary.main, 0.3)}`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function Slider(
|
||||
{ allowed, components, ...props }: SliderProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
return (
|
||||
<StyledSlider
|
||||
ref={ref}
|
||||
components={{
|
||||
Rail: SliderRail({
|
||||
value: allowed,
|
||||
max: props.max,
|
||||
marks: props.marks,
|
||||
step: props.step,
|
||||
}),
|
||||
...components,
|
||||
}}
|
||||
color="primary"
|
||||
{...props}
|
||||
marks={allowed > 0 ? false : props.marks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { materialSliderClasses as sliderClasses };
|
||||
|
||||
Slider.displayName = 'NhostSlider';
|
||||
|
||||
export default forwardRef(Slider);
|
||||
69
dashboard/src/components/ui/v2/Slider/SliderRail.tsx
Normal file
69
dashboard/src/components/ui/v2/Slider/SliderRail.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { BoxProps } from '@/ui/v2/Box';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import { alpha, styled } from '@mui/material';
|
||||
import type { SliderProps as MaterialSliderProps } from '@mui/material/Slider';
|
||||
import MaterialSlider, {
|
||||
sliderClasses as materialSliderClasses,
|
||||
} from '@mui/material/Slider';
|
||||
|
||||
const StyledRail = styled(Box)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
display: 'block',
|
||||
opacity: 1,
|
||||
backgroundColor: theme.palette.grey[200],
|
||||
height: 8,
|
||||
top: '50%',
|
||||
width: '100%',
|
||||
borderRadius: 3,
|
||||
transform: 'translateY(-50%)',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
const StyledInnerSlider = styled(MaterialSlider)(({ theme }) => ({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
height: 6,
|
||||
padding: 0,
|
||||
color: theme.palette.primary.main,
|
||||
[`& .${materialSliderClasses.rail}`]: {
|
||||
height: 6,
|
||||
opacity: 1,
|
||||
background: theme.palette.grey[200],
|
||||
},
|
||||
[`& .${materialSliderClasses.track}`]: {
|
||||
borderRadius: 0,
|
||||
border: 'none',
|
||||
height: 6,
|
||||
backgroundColor:
|
||||
theme.palette.mode === 'light'
|
||||
? alpha(theme.palette.primary.main, 0.1)
|
||||
: alpha(theme.palette.primary.main, 0.15),
|
||||
},
|
||||
[`& .${materialSliderClasses.markActive}`]: {
|
||||
backgroundColor: alpha(theme.palette.primary.main, 0.5),
|
||||
opacity: 1,
|
||||
},
|
||||
}));
|
||||
|
||||
export interface SliderRailProps extends MaterialSliderProps {}
|
||||
|
||||
export default function SliderRail({
|
||||
value,
|
||||
...railAttributes
|
||||
}: SliderRailProps) {
|
||||
return function Rail(props: BoxProps) {
|
||||
return (
|
||||
<StyledRail component="span" {...props}>
|
||||
{value > 0 && (
|
||||
<StyledInnerSlider
|
||||
{...railAttributes}
|
||||
value={value}
|
||||
disabled
|
||||
components={{ Thumb: () => null }}
|
||||
/>
|
||||
)}
|
||||
</StyledRail>
|
||||
);
|
||||
};
|
||||
}
|
||||
2
dashboard/src/components/ui/v2/Slider/index.ts
Normal file
2
dashboard/src/components/ui/v2/Slider/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './Slider';
|
||||
export { default } from './Slider';
|
||||
@@ -27,13 +27,11 @@ const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PK
|
||||
: null;
|
||||
|
||||
type AddPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: () => Promise<void>;
|
||||
onPaymentMethodAdded?: () => void;
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
function AddPaymentMethodForm({
|
||||
close,
|
||||
onPaymentMethodAdded,
|
||||
workspaceId,
|
||||
}: AddPaymentMethodFormProps) {
|
||||
@@ -141,9 +139,7 @@ function AddPaymentMethodForm({
|
||||
|
||||
// payment method added successfylly
|
||||
|
||||
triggerToast(`New payment method added`);
|
||||
|
||||
close();
|
||||
triggerToast('New payment method has been added to the workspace.');
|
||||
|
||||
discordAnnounce(
|
||||
`(${user.email}) added a new credit card to workspace id: ${workspaceId}.`,
|
||||
@@ -205,26 +201,27 @@ function AddPaymentMethodForm({
|
||||
);
|
||||
}
|
||||
|
||||
type BillingPaymentMethodFormProps = {
|
||||
close: () => void;
|
||||
onPaymentMethodAdded?: (e?: any) => Promise<void>;
|
||||
export interface BillingPaymentMethodFormProps {
|
||||
/**
|
||||
* Callback function to run after a payment method is added.
|
||||
*/
|
||||
onPaymentMethodAdded?: (e?: any) => void;
|
||||
/**
|
||||
* Workspace identifier.
|
||||
*/
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function BillingPaymentMethodForm({
|
||||
close,
|
||||
export default function BillingPaymentMethodForm({
|
||||
onPaymentMethodAdded,
|
||||
workspaceId,
|
||||
}: BillingPaymentMethodFormProps) {
|
||||
return (
|
||||
<Elements stripe={stripePromise}>
|
||||
<AddPaymentMethodForm
|
||||
close={close}
|
||||
onPaymentMethodAdded={onPaymentMethodAdded}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
</Elements>
|
||||
);
|
||||
}
|
||||
|
||||
export default BillingPaymentMethodForm;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './BillingPaymentMethodForm';
|
||||
export { default as BillingPaymentMethodForm } from './BillingPaymentMethodForm';
|
||||
@@ -1,19 +1,35 @@
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Alert } from '@/ui/Alert';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Checkbox from '@/ui/v2/Checkbox';
|
||||
import Text from '@/ui/v2/Text';
|
||||
import { useDeleteWorkspaceMutation } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useDeleteWorkspaceMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import getServerError from '@/utils/settings/getServerError';
|
||||
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
|
||||
import router from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
|
||||
export default function RemoveWorkspaceModal() {
|
||||
export interface RemoveWorkspaceModalProps {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called when the operation is cancelled.
|
||||
*/
|
||||
onCancel?: VoidFunction;
|
||||
}
|
||||
|
||||
export default function RemoveWorkspaceModal({
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: RemoveWorkspaceModalProps) {
|
||||
const [remove, setRemove] = useState(false);
|
||||
const { closeDeleteWorkspaceModal } = useUI();
|
||||
|
||||
const [deleteWorkspace, { loading, error: mutationError, client }] =
|
||||
useDeleteWorkspaceMutation();
|
||||
@@ -22,66 +38,63 @@ export default function RemoveWorkspaceModal() {
|
||||
|
||||
async function handleClick() {
|
||||
try {
|
||||
await deleteWorkspace({
|
||||
variables: {
|
||||
id: currentWorkspace.id,
|
||||
await toast.promise(
|
||||
deleteWorkspace({
|
||||
variables: {
|
||||
id: currentWorkspace.id,
|
||||
},
|
||||
}),
|
||||
{
|
||||
loading: 'Deleting workspace...',
|
||||
success: `Workspace "${currentWorkspace.name}" has been deleted successfully.`,
|
||||
error: getServerError(
|
||||
`An error occurred while trying to delete the workspace "${currentWorkspace.name}". Please try again.`,
|
||||
),
|
||||
},
|
||||
});
|
||||
triggerToast(`Workspace ${currentWorkspace.name} successfully deleted`);
|
||||
closeDeleteWorkspaceModal();
|
||||
getToastStyleProps(),
|
||||
);
|
||||
} catch (error) {
|
||||
// TODO: Display error to user and use a logging solution
|
||||
return;
|
||||
}
|
||||
await onSubmit?.();
|
||||
await router.push('/');
|
||||
await client.refetchQueries({ include: ['getOneUser'] });
|
||||
await client.refetchQueries({
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="w-modal rounded-lg p-6 text-left">
|
||||
<div className="grid grid-flow-row gap-4">
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2">
|
||||
Delete Workspace
|
||||
</Text>
|
||||
<Box className="grid grid-flow-row gap-4 px-6 pt-4 pb-6">
|
||||
<Box className="border-y py-2">
|
||||
<Checkbox
|
||||
id="accept-remove"
|
||||
label={`I'm sure I want to delete ${currentWorkspace.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Workspace"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Text>There is no way to recover this workspace later.</Text>
|
||||
</div>
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
{mutationError && (
|
||||
<Alert severity="error">{getErrorMessage(mutationError)}</Alert>
|
||||
)}
|
||||
|
||||
<Box className="border-y py-2">
|
||||
<Checkbox
|
||||
id="accept-remove"
|
||||
label={`I'm sure I want to delete ${currentWorkspace.name}`}
|
||||
className="py-2"
|
||||
checked={remove}
|
||||
onChange={(_event, checked) => setRemove(checked)}
|
||||
aria-label="Confirm Delete Workspace"
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove || !!mutationError}
|
||||
className=""
|
||||
loading={loading}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
{mutationError && (
|
||||
<Alert severity="error">{getErrorMessage(mutationError)}</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove || !!mutationError}
|
||||
className=""
|
||||
loading={loading}
|
||||
>
|
||||
Delete Workspace
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
onClick={closeDeleteWorkspaceModal}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outlined" color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { EditWorkspaceNameForm } from '@/components/home/EditWorkspaceNameForm';
|
||||
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Avatar } from '@/ui/Avatar';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Divider from '@/ui/v2/Divider';
|
||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||
@@ -16,12 +14,6 @@ import Image from 'next/image';
|
||||
export default function WorkspaceHeader() {
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const {
|
||||
openDeleteWorkspaceModal,
|
||||
closeDeleteWorkspaceModal,
|
||||
deleteWorkspaceModal,
|
||||
} = useUI();
|
||||
|
||||
const { openDialog } = useDialog();
|
||||
|
||||
const user = nhost.auth.getUser();
|
||||
@@ -36,11 +28,6 @@ export default function WorkspaceHeader() {
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col">
|
||||
<Modal
|
||||
showModal={deleteWorkspaceModal}
|
||||
close={closeDeleteWorkspaceModal}
|
||||
Component={RemoveWorkspaceModal}
|
||||
/>
|
||||
<div className="flex flex-row place-content-between">
|
||||
<div className="flex flex-row items-center">
|
||||
{IS_DEFAULT_WORKSPACE &&
|
||||
@@ -98,7 +85,7 @@ export default function WorkspaceHeader() {
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
PaperProps={{ className: 'mt-1 w-[280px]' }}
|
||||
PaperProps={{ className: 'mt-1 max-w-[280px]' }}
|
||||
menu
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
|
||||
@@ -125,7 +112,7 @@ export default function WorkspaceHeader() {
|
||||
});
|
||||
}}
|
||||
>
|
||||
Change workspace name
|
||||
Change Workspace Name
|
||||
</Dropdown.Item>
|
||||
|
||||
<Divider component="li" sx={{ margin: 0 }} />
|
||||
@@ -133,18 +120,34 @@ export default function WorkspaceHeader() {
|
||||
<Dropdown.Item
|
||||
className="grid grid-flow-row whitespace-pre-wrap py-2 font-medium"
|
||||
disabled={!noApplications}
|
||||
onClick={openDeleteWorkspaceModal}
|
||||
onClick={() =>
|
||||
openDialog({
|
||||
title: (
|
||||
<span className="grid grid-flow-row">
|
||||
<span>Delete Workspace</span>
|
||||
|
||||
<Text variant="subtitle1" component="span">
|
||||
There is no way to recover this workspace later.
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
component: <RemoveWorkspaceModal />,
|
||||
props: {
|
||||
titleProps: { className: '!pb-0' },
|
||||
},
|
||||
})
|
||||
}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
I want to remove this workspace
|
||||
Delete Workspace
|
||||
{!noApplications && (
|
||||
<Text
|
||||
variant="caption"
|
||||
className="font-medium"
|
||||
color="disabled"
|
||||
>
|
||||
You can't remove this workspace because you have apps
|
||||
running. Remove all apps first.
|
||||
You can't delete this workspace because you have
|
||||
projects running. Delete all projects first.
|
||||
</Text>
|
||||
)}
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BillingPaymentMethodForm } from '@/components/billing-payment-method/BillingPaymentMethodForm';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { BillingPaymentMethodForm } from '@/components/workspace/BillingPaymentMethodForm';
|
||||
import type { GetPaymentMethodsFragment } from '@/generated/graphql';
|
||||
import {
|
||||
refetchGetPaymentMethodsQuery,
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
useSetNewDefaultPaymentMethodMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { Modal } from '@/ui/Modal';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||
import Button from '@/ui/v2/Button';
|
||||
import Table from '@/ui/v2/Table';
|
||||
@@ -21,8 +20,6 @@ import Text from '@/ui/v2/Text';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
function CheckCircle() {
|
||||
const theme = useTheme();
|
||||
@@ -44,15 +41,8 @@ function CheckCircle() {
|
||||
}
|
||||
|
||||
export default function WorkspacePaymentMethods() {
|
||||
const router = useRouter();
|
||||
const { action } = router.query;
|
||||
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const [showAddPaymentMethodModal, setShowAddPaymentMethodModal] = useState(
|
||||
action === 'add-payment-method',
|
||||
);
|
||||
const { openAlertDialog, openDialog, closeDialog } = useDialog();
|
||||
|
||||
const { loading, error, data } = useGetPaymentMethodsQuery({
|
||||
variables: {
|
||||
@@ -230,7 +220,14 @@ export default function WorkspacePaymentMethods() {
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
setShowAddPaymentMethodModal(true);
|
||||
openDialog({
|
||||
component: (
|
||||
<BillingPaymentMethodForm
|
||||
workspaceId={currentWorkspace.id}
|
||||
onPaymentMethodAdded={closeDialog}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}}
|
||||
disabled={maxPaymentMethodsReached}
|
||||
>
|
||||
@@ -244,19 +241,6 @@ export default function WorkspacePaymentMethods() {
|
||||
payment methods.
|
||||
</Text>
|
||||
)}
|
||||
{showAddPaymentMethodModal && (
|
||||
<Modal
|
||||
showModal={showAddPaymentMethodModal}
|
||||
close={() =>
|
||||
setShowAddPaymentMethodModal(!showAddPaymentMethodModal)
|
||||
}
|
||||
>
|
||||
<BillingPaymentMethodForm
|
||||
workspaceId={currentWorkspace.id}
|
||||
close={() => setShowAddPaymentMethodModal(false)}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { useRouter } from 'next/router';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, useContext, useMemo, useReducer } from 'react';
|
||||
import { createContext, useContext, useMemo } from 'react';
|
||||
|
||||
export interface UIContextState {
|
||||
newWorkspace: boolean;
|
||||
modal: boolean;
|
||||
deleteApplicationModal: boolean;
|
||||
deleteWorkspaceModal: boolean;
|
||||
resourcesCollapsible: boolean;
|
||||
paymentModal: boolean;
|
||||
/**
|
||||
* Determines whether or not the dashboard is in maintenance mode.
|
||||
*/
|
||||
@@ -17,61 +11,18 @@ export interface UIContextState {
|
||||
* The date and time when maintenance mode will end.
|
||||
*/
|
||||
maintenanceEndDate: Date;
|
||||
openPaymentModal: () => void;
|
||||
closePaymentModal: () => void;
|
||||
openDeleteWorkspaceModal: () => void;
|
||||
closeDeleteWorkspaceModal: () => void;
|
||||
}
|
||||
|
||||
const initialState: UIContextState = {
|
||||
newWorkspace: false,
|
||||
modal: false,
|
||||
deleteApplicationModal: false,
|
||||
deleteWorkspaceModal: false,
|
||||
resourcesCollapsible: true,
|
||||
paymentModal: false,
|
||||
export const UIContext = createContext<UIContextState>({
|
||||
maintenanceActive: false,
|
||||
maintenanceEndDate: null,
|
||||
openPaymentModal: () => {},
|
||||
closePaymentModal: () => {},
|
||||
openDeleteWorkspaceModal: () => {},
|
||||
closeDeleteWorkspaceModal: () => {},
|
||||
};
|
||||
|
||||
export const UIContext = createContext<UIContextState>(initialState);
|
||||
});
|
||||
|
||||
UIContext.displayName = 'UIContext';
|
||||
|
||||
function sideReducer(state: any, action: any) {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_DELETE_WORKSPACE_MODAL': {
|
||||
return {
|
||||
...state,
|
||||
deleteWorkspaceModal: !state.deleteWorkspaceModal,
|
||||
};
|
||||
}
|
||||
case 'TOGGLE_PAYMENT_MODAL': {
|
||||
return {
|
||||
...state,
|
||||
paymentModal: !state.paymentModal,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
const [state, dispatch] = useReducer(sideReducer, initialState);
|
||||
const router = useRouter();
|
||||
|
||||
const openPaymentModal = () => dispatch({ type: 'TOGGLE_PAYMENT_MODAL' });
|
||||
const closePaymentModal = () => dispatch({ type: 'TOGGLE_PAYMENT_MODAL' });
|
||||
const openDeleteWorkspaceModal = () =>
|
||||
dispatch({ type: 'TOGGLE_DELETE_WORKSPACE_MODAL' });
|
||||
const closeDeleteWorkspaceModal = () =>
|
||||
dispatch({ type: 'TOGGLE_DELETE_WORKSPACE_MODAL' });
|
||||
|
||||
const maintenanceUnlocked =
|
||||
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET &&
|
||||
process.env.NEXT_PUBLIC_MAINTENANCE_UNLOCK_SECRET ===
|
||||
@@ -79,11 +30,6 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
|
||||
const value: UIContextState = useMemo(
|
||||
() => ({
|
||||
...state,
|
||||
openDeleteWorkspaceModal,
|
||||
closeDeleteWorkspaceModal,
|
||||
openPaymentModal,
|
||||
closePaymentModal,
|
||||
maintenanceActive: maintenanceUnlocked
|
||||
? false
|
||||
: process.env.NEXT_PUBLIC_MAINTENANCE_ACTIVE === 'true',
|
||||
@@ -93,7 +39,7 @@ export function UIProvider(props: PropsWithChildren<unknown>) {
|
||||
? new Date(Date.parse(process.env.NEXT_PUBLIC_MAINTENANCE_END_DATE))
|
||||
: null,
|
||||
}),
|
||||
[state, maintenanceUnlocked],
|
||||
[maintenanceUnlocked],
|
||||
);
|
||||
|
||||
return <UIContext.Provider value={value} {...props} />;
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import { createContext, useContext, useMemo, useState } from 'react';
|
||||
|
||||
type Metadata = {
|
||||
lastWorkspace: string;
|
||||
template?: string;
|
||||
};
|
||||
|
||||
type UserContextData = {
|
||||
workspaces: Workspace[];
|
||||
metadata?: Metadata;
|
||||
};
|
||||
|
||||
export type UserDataContent = {
|
||||
userContext: UserContextData;
|
||||
setUserContext: (d: UserContextData) => void;
|
||||
};
|
||||
|
||||
export const UserDataContext = createContext<UserDataContent>({
|
||||
userContext: {
|
||||
workspaces: [],
|
||||
metadata: { lastWorkspace: '' },
|
||||
},
|
||||
setUserContext: () => {},
|
||||
});
|
||||
|
||||
export interface UserDataProviderProps {
|
||||
/**
|
||||
* Initial workspaces to be used in the context.
|
||||
*/
|
||||
initialWorkspaces?: Workspace[];
|
||||
/**
|
||||
* Initial metadata to be used in the context.
|
||||
*/
|
||||
initialMetadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export function UserDataProvider({
|
||||
children,
|
||||
initialWorkspaces,
|
||||
initialMetadata,
|
||||
}: PropsWithChildren<UserDataProviderProps>) {
|
||||
const [userContext, setUserContext] = useState({
|
||||
workspaces: initialWorkspaces || [],
|
||||
metadata: initialMetadata || {},
|
||||
});
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ userContext, setUserContext }),
|
||||
[userContext, setUserContext],
|
||||
);
|
||||
|
||||
return (
|
||||
// @ts-ignore
|
||||
<UserDataContext.Provider value={value}>
|
||||
{children}
|
||||
</UserDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useUserDataContext = () => {
|
||||
const context = useContext(UserDataContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error(`useUserDataContext must be used under a UserDataProvider`);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { default as prettifyMemory } from './prettifyMemory';
|
||||
@@ -0,0 +1,17 @@
|
||||
import { RESOURCE_MEMORY_MULTIPLIER } from '@/utils/CONSTANTS';
|
||||
import { prettifyNumber } from '@/utils/common/prettifyNumber';
|
||||
|
||||
/**
|
||||
* Prettifies a number of memory.
|
||||
*
|
||||
* @param vcpu - The number of memory.
|
||||
* @returns The prettified number of memory.
|
||||
*/
|
||||
export default function prettifyMemory(memory: number) {
|
||||
return prettifyNumber(memory, {
|
||||
labels: ['MiB'],
|
||||
numberOfDecimals: 3,
|
||||
separator: ' ',
|
||||
multiplier: RESOURCE_MEMORY_MULTIPLIER,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as prettifyVCPU } from './prettifyVCPU';
|
||||
@@ -0,0 +1,11 @@
|
||||
import { RESOURCE_VCPU_MULTIPLIER } from '@/utils/CONSTANTS';
|
||||
|
||||
/**
|
||||
* Prettifies a number of vCPUs.
|
||||
*
|
||||
* @param vcpu - The number of vCPUs.
|
||||
* @returns The prettified number of vCPUs.
|
||||
*/
|
||||
export default function prettifyVCPU(vcpu: number) {
|
||||
return vcpu / RESOURCE_VCPU_MULTIPLIER;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './resourceSettingsValidationSchema';
|
||||
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
RESOURCE_MEMORY_MULTIPLIER,
|
||||
RESOURCE_VCPU_MEMORY_RATIO,
|
||||
RESOURCE_VCPU_MULTIPLIER,
|
||||
} from '@/utils/CONSTANTS';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
/**
|
||||
* The minimum total CPU that has to be allocated.
|
||||
*/
|
||||
export const MIN_TOTAL_VCPU = 1 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The minimum amount of memory that has to be allocated in total.
|
||||
*/
|
||||
export const MIN_TOTAL_MEMORY =
|
||||
(MIN_TOTAL_VCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The maximum total CPU that can be allocated.
|
||||
*/
|
||||
export const MAX_TOTAL_VCPU = 60 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The maximum amount of memory that can be allocated in total.
|
||||
*/
|
||||
export const MAX_TOTAL_MEMORY = MAX_TOTAL_VCPU * RESOURCE_VCPU_MEMORY_RATIO;
|
||||
|
||||
/**
|
||||
* The minimum amount of CPU that has to be allocated per service.
|
||||
*/
|
||||
export const MIN_SERVICE_VCPU = 0.25 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The maximum amount of CPU that can be allocated per service.
|
||||
*/
|
||||
export const MAX_SERVICE_VCPU = 15 * RESOURCE_VCPU_MULTIPLIER;
|
||||
|
||||
/**
|
||||
* The minimum amount of memory that has to be allocated per service.
|
||||
*/
|
||||
export const MIN_SERVICE_MEMORY = 128;
|
||||
|
||||
/**
|
||||
* The maximum amount of memory that can be allocated per service.
|
||||
*/
|
||||
export const MAX_SERVICE_MEMORY =
|
||||
(MAX_SERVICE_VCPU / RESOURCE_VCPU_MULTIPLIER) *
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
export const resourceSettingsValidationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
totalAvailableVCPU: Yup.number()
|
||||
.label('Total Available vCPUs')
|
||||
.required()
|
||||
.min(MIN_TOTAL_VCPU)
|
||||
.max(MAX_TOTAL_VCPU),
|
||||
totalAvailableMemory: Yup.number()
|
||||
.label('Available Memory')
|
||||
.required()
|
||||
.min(MIN_TOTAL_MEMORY)
|
||||
.max(MAX_TOTAL_MEMORY),
|
||||
databaseVCPU: Yup.number()
|
||||
.label('Database vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
databaseMemory: Yup.number()
|
||||
.label('Database Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
hasuraVCPU: Yup.number()
|
||||
.label('Hasura GraphQL vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
hasuraMemory: Yup.number()
|
||||
.label('Hasura GraphQL Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
authVCPU: Yup.number()
|
||||
.label('Auth vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
authMemory: Yup.number()
|
||||
.label('Auth Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
storageVCPU: Yup.number()
|
||||
.label('Storage vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
storageMemory: Yup.number()
|
||||
.label('Storage Memory')
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY),
|
||||
});
|
||||
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
typeof resourceSettingsValidationSchema
|
||||
>;
|
||||
@@ -1,12 +0,0 @@
|
||||
query getAllAppsWhere($where: apps_bool_exp!) {
|
||||
apps(where: $where) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
fragment GetAppByWorkspaceAndName on apps {
|
||||
updatedAt
|
||||
id
|
||||
slug
|
||||
subdomain
|
||||
name
|
||||
createdAt
|
||||
isProvisioned
|
||||
providersUpdated
|
||||
githubRepository {
|
||||
id
|
||||
name
|
||||
githubAppInstallation {
|
||||
id
|
||||
accountLogin
|
||||
}
|
||||
}
|
||||
repositoryProductionBranch
|
||||
githubRepositoryId
|
||||
region {
|
||||
countryCode
|
||||
city
|
||||
}
|
||||
workspace {
|
||||
name
|
||||
slug
|
||||
id
|
||||
}
|
||||
workspaceId
|
||||
config(resolve: true) {
|
||||
hasura {
|
||||
adminSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query getAppByWorkspaceAndName($workspace: String!, $slug: String!) {
|
||||
apps(
|
||||
where: { workspace: { slug: { _eq: $workspace } }, slug: { _eq: $slug } }
|
||||
) {
|
||||
...GetAppByWorkspaceAndName
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
query getApps {
|
||||
apps(order_by: { createdAt: desc }) {
|
||||
id
|
||||
slug
|
||||
name
|
||||
subdomain
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
query getAppProvisionStatus($workspace: String!, $slug: String!) {
|
||||
apps(
|
||||
where: { workspace: { slug: { _eq: $workspace } }, slug: { _eq: $slug } }
|
||||
) {
|
||||
id
|
||||
isProvisioned
|
||||
subdomain
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
fragment ServiceResources on ConfigConfig {
|
||||
auth {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
hasura {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
postgres {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
storage {
|
||||
resources {
|
||||
compute {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
query GetResources($appId: uuid!) {
|
||||
config(appID: $appId, resolve: true) {
|
||||
...ServiceResources
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation updateApp($id: uuid!, $app: apps_set_input!) {
|
||||
updateApp(pk_columns: { id: $id }, _set: $app) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -9,17 +9,6 @@ fragment DeploymentRow on deployments {
|
||||
commitMessage
|
||||
}
|
||||
|
||||
query getDeployments($id: uuid!, $limit: Int!, $offset: Int!) {
|
||||
deployments(
|
||||
where: { appId: { _eq: $id } }
|
||||
order_by: { deploymentStartedAt: desc }
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
) {
|
||||
...DeploymentRow
|
||||
}
|
||||
}
|
||||
|
||||
subscription ScheduledOrPendingDeploymentsSub($appId: uuid!) {
|
||||
deployments(
|
||||
where: { deploymentStatus: { _in: ["SCHEDULED"] }, appId: { _eq: $appId } }
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation insertFeatureFlag($flag: featureFlags_insert_input!) {
|
||||
insertFeatureFlag(object: $flag){
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation deleteFiles($fileIds: [uuid!]!) {
|
||||
deleteFiles(where: { id: { _in: $fileIds } }) {
|
||||
affected_rows
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ fragment Project on apps {
|
||||
plan {
|
||||
id
|
||||
name
|
||||
price
|
||||
isFree
|
||||
}
|
||||
githubRepository {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
mutation changePaymentMethod(
|
||||
$workspaceId: uuid!
|
||||
$paymentMethod: paymentMethods_insert_input!
|
||||
) {
|
||||
# delete all cards on the current workspace
|
||||
deletePaymentMethods(where: { workspaceId: { _eq: $workspaceId } }) {
|
||||
affected_rows
|
||||
}
|
||||
# add new
|
||||
insertPaymentMethod(object: $paymentMethod) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,8 @@
|
||||
query getPlans {
|
||||
plans(order_by: { sort: asc }) {
|
||||
query GetPlans($where: plans_bool_exp) {
|
||||
plans(where: $where) {
|
||||
id
|
||||
name
|
||||
isFree
|
||||
price
|
||||
isDefault
|
||||
}
|
||||
regions {
|
||||
id
|
||||
isGdprCompliant
|
||||
city
|
||||
country {
|
||||
name
|
||||
continent {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
workspaces {
|
||||
id
|
||||
name
|
||||
slug
|
||||
paymentMethods {
|
||||
id
|
||||
cardBrand
|
||||
cardLast4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
query getRemoteAppFilesUsage {
|
||||
filesAggregate {
|
||||
aggregate {
|
||||
count
|
||||
sum {
|
||||
size
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
fragment GetRemoteAppUser on users {
|
||||
id
|
||||
createdAt
|
||||
displayName
|
||||
locale
|
||||
avatarUrl
|
||||
email
|
||||
emailVerified
|
||||
passwordHash
|
||||
locale
|
||||
disabled
|
||||
phoneNumber
|
||||
phoneNumberVerified
|
||||
defaultRole
|
||||
roles {
|
||||
role
|
||||
}
|
||||
userProviders {
|
||||
id
|
||||
provider {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment GetRemoteAppUserAuthRoles on authRoles {
|
||||
role
|
||||
}
|
||||
|
||||
query getRemoteAppUser($id: uuid!) {
|
||||
user(id: $id) {
|
||||
...GetRemoteAppUser
|
||||
}
|
||||
authRoles {
|
||||
role
|
||||
}
|
||||
}
|
||||
|
||||
query getRemoteAppUserWhere($where: users_bool_exp!) {
|
||||
users(where: $where) {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
defaultRole
|
||||
}
|
||||
}
|
||||
|
||||
query getRemoteAppById($id: uuid!) {
|
||||
user(id: $id) {
|
||||
id
|
||||
displayName
|
||||
email
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mutation confirmProvidersUpdated($id: uuid!) {
|
||||
updateApp(pk_columns: { id: $id }, _set: { providersUpdated: true }) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
query getAllUserData {
|
||||
workspaceMembers {
|
||||
id
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
creatorUserId
|
||||
apps {
|
||||
id
|
||||
name
|
||||
subdomain
|
||||
config(resolve: true) {
|
||||
hasura {
|
||||
adminSecret
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
query GetAvatar($userId: uuid!) {
|
||||
user(id: $userId) {
|
||||
id
|
||||
avatarUrl
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
query getOneUser($userId: uuid!) {
|
||||
user(id: $userId) {
|
||||
id
|
||||
displayName
|
||||
avatarUrl
|
||||
workspaceMembers {
|
||||
id
|
||||
userId
|
||||
workspaceId
|
||||
type
|
||||
workspace {
|
||||
creatorUserId
|
||||
id
|
||||
slug
|
||||
name
|
||||
apps {
|
||||
...Project
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
query getUserAllWorkspaces {
|
||||
workspaceMembers {
|
||||
id
|
||||
userId
|
||||
workspace {
|
||||
id
|
||||
name
|
||||
slug
|
||||
apps {
|
||||
id
|
||||
name
|
||||
plan {
|
||||
id
|
||||
name
|
||||
}
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
query getAppsByWorkspace($workspace_id: uuid!) {
|
||||
workspace(id: $workspace_id) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
apps {
|
||||
name
|
||||
plan {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
query getWorkspaceInvoices($id: uuid!) {
|
||||
workspace(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
query getWorkspaceSettings($id: uuid!) {
|
||||
workspace(id: $id) {
|
||||
id
|
||||
name
|
||||
addressLine1
|
||||
addressLine2
|
||||
addressPostalCode
|
||||
addressPostalCode
|
||||
addressCity
|
||||
addressState
|
||||
addressCountryCode
|
||||
companyName
|
||||
email
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
fragment GetWorkspace on workspaces {
|
||||
id
|
||||
name
|
||||
email
|
||||
companyName
|
||||
addressLine1
|
||||
addressLine2
|
||||
addressPostalCode
|
||||
addressCity
|
||||
addressCountryCode
|
||||
slug
|
||||
taxIdType
|
||||
taxIdValue
|
||||
apps {
|
||||
id
|
||||
name
|
||||
slug
|
||||
createdAt
|
||||
workspace {
|
||||
id
|
||||
slug
|
||||
}
|
||||
}
|
||||
paymentMethods {
|
||||
id
|
||||
cardBrand
|
||||
cardLast4
|
||||
stripePaymentMethodId
|
||||
}
|
||||
workspaceMembers {
|
||||
id
|
||||
user {
|
||||
id
|
||||
}
|
||||
type
|
||||
}
|
||||
}
|
||||
|
||||
query getWorkspace($id: uuid!) {
|
||||
workspace(id: $id) {
|
||||
...GetWorkspace
|
||||
}
|
||||
}
|
||||
|
||||
query getWorkspaceWhere($where: workspaces_bool_exp!) {
|
||||
workspaces(where: $where) {
|
||||
...GetWorkspace
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
query GetWorkspacesAppsById($workspaceId: uuid!) {
|
||||
workspace(id: $workspaceId) {
|
||||
id
|
||||
slug
|
||||
apps {
|
||||
id
|
||||
name
|
||||
slug
|
||||
updatedAt
|
||||
plan {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
query getWorkspaces {
|
||||
workspaces(order_by: { name: asc }) {
|
||||
id
|
||||
createdAt
|
||||
name
|
||||
slug
|
||||
creatorUserId
|
||||
}
|
||||
}
|
||||
1
dashboard/src/hooks/common/useProPlan/index.ts
Normal file
1
dashboard/src/hooks/common/useProPlan/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './useProPlan';
|
||||
16
dashboard/src/hooks/common/useProPlan/useProPlan.ts
Normal file
16
dashboard/src/hooks/common/useProPlan/useProPlan.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { useGetPlansQuery } from '@/utils/__generated__/graphql';
|
||||
|
||||
export default function useProPlan() {
|
||||
const { data, ...rest } = useGetPlansQuery({
|
||||
variables: {
|
||||
where: {
|
||||
name: {
|
||||
_eq: 'Pro',
|
||||
},
|
||||
},
|
||||
},
|
||||
fetchPolicy: 'cache-and-network',
|
||||
});
|
||||
|
||||
return { data: data?.plans?.at(0), ...rest };
|
||||
}
|
||||
@@ -4,7 +4,10 @@ import type {
|
||||
GetApplicationStateQuery,
|
||||
GetApplicationStateQueryVariables,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { useGetApplicationStateQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
useGetApplicationStateQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import type { QueryHookOptions } from '@apollo/client';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
@@ -31,7 +34,7 @@ export default function useProjectRedirectWhenReady(
|
||||
useEffect(() => {
|
||||
async function updateOwnCache() {
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useGetUserAllWorkspacesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
function checkForApplicationsOnAllWorkspaces(workspaces, setNoApplications) {
|
||||
let noApplications = true;
|
||||
|
||||
workspaces.forEach(({ workspace }) => {
|
||||
if (noApplications && workspace.apps.length !== 0) {
|
||||
noApplications = false;
|
||||
}
|
||||
});
|
||||
|
||||
setNoApplications(noApplications);
|
||||
}
|
||||
|
||||
export function useCheckApplications() {
|
||||
const { data, loading, error } = useGetUserAllWorkspacesQuery();
|
||||
const [noApplications, setNoApplications] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { workspaceMembers } = data;
|
||||
const noWorkspaces = workspaceMembers?.length === 0;
|
||||
|
||||
if (noWorkspaces) {
|
||||
setNoApplications(true);
|
||||
}
|
||||
|
||||
checkForApplicationsOnAllWorkspaces(workspaceMembers, setNoApplications);
|
||||
}, [data, loading, noApplications, setNoApplications]);
|
||||
|
||||
return { data, loading, error, noApplications };
|
||||
}
|
||||
|
||||
export default useCheckApplications;
|
||||
@@ -1,6 +1,5 @@
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetOneUserDocument,
|
||||
useGetApplicationStateQuery,
|
||||
} from '@/generated/graphql';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
@@ -21,33 +20,33 @@ type ApplicationStateMetadata = {
|
||||
* it will update the entire cache with the application state.
|
||||
*/
|
||||
export function useCheckProvisioning() {
|
||||
const { currentWorkspace } = useCurrentWorkspaceAndProject();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const [currentApplicationState, setCurrentApplicationState] =
|
||||
useState<ApplicationStateMetadata>({ state: ApplicationStatus.Empty });
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const { data, startPolling, stopPolling, client } =
|
||||
useGetApplicationStateQuery({
|
||||
variables: { appId: currentWorkspace?.id },
|
||||
skip: !isPlatform || !currentWorkspace?.id,
|
||||
variables: { appId: currentProject?.id },
|
||||
skip: !isPlatform || !currentProject?.id,
|
||||
});
|
||||
|
||||
async function updateOwnCache() {
|
||||
await client.refetchQueries({
|
||||
include: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
}
|
||||
|
||||
const memoizedUpdateCache = useCallback(updateOwnCache, [client]);
|
||||
|
||||
const currentApplicationId = currentWorkspace?.id;
|
||||
const currentApplicationId = currentProject?.id;
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(2000);
|
||||
}, [startPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) {
|
||||
if (!data?.app) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useUserDataContext } from '@/context/UserDataContext';
|
||||
import { useGetOneUserLazyQuery } from '@/generated/graphql';
|
||||
import type { Workspace } from '@/types/workspace';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { useEffect, useState } from 'react';
|
||||
import useIsPlatform from './common/useIsPlatform';
|
||||
import { useWithin } from './useWithin';
|
||||
|
||||
export type UserData = {
|
||||
workspaces: Workspace[] | [];
|
||||
};
|
||||
|
||||
export function useGetAllUserWorkspacesAndApplications(
|
||||
fromState: boolean = false,
|
||||
) {
|
||||
const { userContext, setUserContext } = useUserDataContext();
|
||||
const [userData, setUserData] = useState<UserData | null>(null);
|
||||
const isPlatform = useIsPlatform();
|
||||
const { within } = useWithin();
|
||||
|
||||
const user = nhost.auth.getUser();
|
||||
|
||||
const [getAllUserData, { loading, data, called }] = useGetOneUserLazyQuery({
|
||||
variables: {
|
||||
userId: user?.id,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data || !isPlatform) {
|
||||
return;
|
||||
}
|
||||
|
||||
getAllUserData();
|
||||
}, [data, isPlatform, getAllUserData]);
|
||||
|
||||
// TODO: This useEffect should be broken down into multiple smaller parts
|
||||
// because dependency array is not expandable with the necessary dependencies
|
||||
// in its current form.
|
||||
useEffect(() => {
|
||||
if (data && userData && userData.workspaces.length !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (within && !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
within &&
|
||||
data &&
|
||||
data.user?.workspaceMembers &&
|
||||
data.user?.workspaceMembers.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
data?.user?.workspaceMembers &&
|
||||
data?.user?.workspaceMembers.length !== 0
|
||||
) {
|
||||
const workspaces = data.user.workspaceMembers.map(({ workspace }) => {
|
||||
// note: this could be rather defined by the infrastructure when
|
||||
// creating the initial workspace
|
||||
const isDefaultWorkspace =
|
||||
workspace.name.toLowerCase() === 'default workspace' &&
|
||||
workspace.creatorUserId === user?.id &&
|
||||
/default-workspace-[a-z]+/i.test(workspace.slug);
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
slug: workspace.slug,
|
||||
creatorUserId: workspace.creatorUserId,
|
||||
default: isDefaultWorkspace,
|
||||
members: data.user.workspaceMembers.filter(
|
||||
({ workspaceId }) => workspaceId === workspace.id,
|
||||
),
|
||||
applications: workspace.apps.map((app) => {
|
||||
const userContextAppProps: any = {
|
||||
users: 0,
|
||||
userMetrics: {
|
||||
growth: 0,
|
||||
difference: 0,
|
||||
growthPercentage: 0,
|
||||
totalUsers: 0,
|
||||
},
|
||||
dbSize: 0,
|
||||
};
|
||||
|
||||
if (userContext.workspaces?.length > 0) {
|
||||
const currentWorkspace = userContext.workspaces.find(
|
||||
(x) => x.id === workspace.id,
|
||||
);
|
||||
|
||||
const currentApp = currentWorkspace?.applications.find(
|
||||
(x) => x.id === app.id,
|
||||
);
|
||||
|
||||
if (currentWorkspace && currentApp) {
|
||||
return {
|
||||
...app,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...app,
|
||||
...userContextAppProps,
|
||||
};
|
||||
}),
|
||||
} as Workspace;
|
||||
});
|
||||
|
||||
if (fromState) {
|
||||
setUserData({ workspaces });
|
||||
} else {
|
||||
setUserContext({ workspaces, metadata: userContext.metadata });
|
||||
}
|
||||
}
|
||||
}, [data, setUserData, called]);
|
||||
|
||||
return { userData, setUserData, getAllUserData, loading, data, called };
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
|
||||
export function useLazyRefetchUserData() {
|
||||
const client = useApolloClient();
|
||||
|
||||
const refetchUserData = async () => {
|
||||
await client.refetchQueries({
|
||||
include: ['getOneUser'],
|
||||
});
|
||||
};
|
||||
|
||||
return { refetchUserData };
|
||||
}
|
||||
|
||||
export default useLazyRefetchUserData;
|
||||
@@ -24,6 +24,10 @@ export interface UseCurrentWorkspaceAndProjectReturnType {
|
||||
* The error if any.
|
||||
*/
|
||||
error?: Error;
|
||||
/**
|
||||
* Refetch the query.
|
||||
*/
|
||||
refetch: (options?: any) => Promise<any>;
|
||||
}
|
||||
|
||||
export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndProjectReturnType {
|
||||
@@ -39,7 +43,11 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
// We can't use the hook exported by the codegen here because there are cases
|
||||
// where it doesn't target the Nhost backend, but the currently active project
|
||||
// instead.
|
||||
const { data: response, isFetching } = useQuery(
|
||||
const {
|
||||
data: response,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useQuery(
|
||||
['currentWorkspaceAndProject', workspaceSlug, appSlug],
|
||||
() =>
|
||||
client.graphql.request<{
|
||||
@@ -105,6 +113,7 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
},
|
||||
currentProject: localProject,
|
||||
loading: false,
|
||||
refetch: () => Promise.resolve(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -122,5 +131,6 @@ export default function useCurrentWorkspaceAndProject(): UseCurrentWorkspaceAndP
|
||||
error: response?.error
|
||||
? new Error(error?.message || 'Unknown error occurred.')
|
||||
: null,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function AppIndexPage() {
|
||||
return <ApplicationLive />;
|
||||
case ApplicationStatus.Errored:
|
||||
return <ApplicationErrored />;
|
||||
case ApplicationStatus.Pausing:
|
||||
case ApplicationStatus.Paused:
|
||||
return <ApplicationPaused />;
|
||||
case ApplicationStatus.Unpausing:
|
||||
|
||||
@@ -7,7 +7,6 @@ import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetOneUserDocument,
|
||||
useDeleteApplicationMutation,
|
||||
usePauseApplicationMutation,
|
||||
useUpdateApplicationMutation,
|
||||
@@ -44,11 +43,11 @@ export default function SettingsGeneralPage() {
|
||||
const client = useApolloClient();
|
||||
const [pauseApplication] = usePauseApplicationMutation({
|
||||
variables: { appId: currentProject?.id },
|
||||
refetchQueries: [GetOneUserDocument],
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
const [deleteApplication] = useDeleteApplicationMutation({
|
||||
variables: { appId: currentProject?.id },
|
||||
refetchQueries: [GetOneUserDocument],
|
||||
refetchQueries: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
const router = useRouter();
|
||||
const { maintenanceActive } = useUI();
|
||||
@@ -118,7 +117,7 @@ export default function SettingsGeneralPage() {
|
||||
`/${currentWorkspace.slug}/${newProjectSlug}/settings/general`,
|
||||
);
|
||||
await client.refetchQueries({
|
||||
include: [GetOneUserDocument, GetAllWorkspacesAndProjectsDocument],
|
||||
include: [GetAllWorkspacesAndProjectsDocument],
|
||||
});
|
||||
} catch (error) {
|
||||
await discordAnnounce(
|
||||
@@ -235,7 +234,6 @@ export default function SettingsGeneralPage() {
|
||||
disabled: maintenanceActive,
|
||||
onClick: () => {
|
||||
openDialog({
|
||||
title: '',
|
||||
component: (
|
||||
<RemoveApplicationModal
|
||||
close={closeDialog}
|
||||
@@ -244,7 +242,6 @@ export default function SettingsGeneralPage() {
|
||||
),
|
||||
props: {
|
||||
PaperProps: { className: 'max-w-sm' },
|
||||
hideTitle: true,
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import DeploymentBranchSettings from '@/components/settings/git/DeploymentBranch
|
||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import { useUI } from '@/context/UIContext';
|
||||
import { useUpdateAppMutation } from '@/generated/graphql';
|
||||
import { useUpdateApplicationMutation } from '@/generated/graphql';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import Box from '@/ui/v2/Box';
|
||||
import Button from '@/ui/v2/Button';
|
||||
@@ -24,7 +24,7 @@ export default function SettingsGitPage() {
|
||||
const { openAlertDialog } = useDialog();
|
||||
const client = useApolloClient();
|
||||
|
||||
const [updateApp] = useUpdateAppMutation();
|
||||
const [updateApp] = useUpdateApplicationMutation();
|
||||
|
||||
return (
|
||||
<Container
|
||||
@@ -73,7 +73,7 @@ export default function SettingsGitPage() {
|
||||
onPrimaryAction: async () => {
|
||||
await updateApp({
|
||||
variables: {
|
||||
id: currentProject.id,
|
||||
appId: currentProject.id,
|
||||
app: {
|
||||
githubRepositoryId: null,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UnlockFeatureByUpgrading } from '@/components/applications/UnlockFeatureByUpgrading';
|
||||
import Container from '@/components/layout/Container';
|
||||
import SettingsLayout from '@/components/settings/SettingsLayout';
|
||||
import ResourcesForm from '@/components/settings/resources/ResourcesForm';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator/ActivityIndicator';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
export default function ResourceSettingsPage() {
|
||||
const { currentProject, loading } = useCurrentWorkspaceAndProject();
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={1000} label="Loading project..." />;
|
||||
}
|
||||
|
||||
if (currentProject?.plan.isFree) {
|
||||
return (
|
||||
<UnlockFeatureByUpgrading message="Unlock Compute settings by upgrading your project to the Pro plan." />
|
||||
);
|
||||
}
|
||||
|
||||
return <ResourcesForm />;
|
||||
}
|
||||
|
||||
ResourceSettingsPage.getLayout = function getLayout(page: ReactElement) {
|
||||
return (
|
||||
<SettingsLayout>
|
||||
<Container
|
||||
className="grid max-w-5xl grid-flow-row gap-8 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
{page}
|
||||
</Container>
|
||||
</SettingsLayout>
|
||||
);
|
||||
};
|
||||
@@ -10,7 +10,6 @@ import GitHubProviderSettings from '@/components/settings/signInMethods/GitHubPr
|
||||
import GoogleProviderSettings from '@/components/settings/signInMethods/GoogleProviderSettings';
|
||||
import LinkedInProviderSettings from '@/components/settings/signInMethods/LinkedInProviderSettings';
|
||||
import MagicLinkSettings from '@/components/settings/signInMethods/MagicLinkSettings';
|
||||
import ProvidersUpdatedAlert from '@/components/settings/signInMethods/ProvidersUpdatedAlert';
|
||||
import SMSSettings from '@/components/settings/signInMethods/SMSSettings';
|
||||
import SpotifyProviderSettings from '@/components/settings/signInMethods/SpotifyProviderSettings';
|
||||
import TwitchProviderSettings from '@/components/settings/signInMethods/TwitchProviderSettings';
|
||||
@@ -55,7 +54,6 @@ export default function SettingsSignInMethodsPage() {
|
||||
<WebAuthnSettings />
|
||||
<AnonymousSignInSettings />
|
||||
<SMSSettings />
|
||||
{!currentProject.providersUpdated && <ProvidersUpdatedAlert />}
|
||||
<AppleProviderSettings />
|
||||
<AzureADProviderSettings />
|
||||
<DiscordProviderSettings />
|
||||
|
||||
@@ -94,10 +94,7 @@ export default function SMTPSettingsPage() {
|
||||
className="grid max-w-5xl grid-flow-row gap-4 bg-transparent"
|
||||
rootClassName="bg-transparent"
|
||||
>
|
||||
<UnlockFeatureByUpgrading
|
||||
message="Unlock SMTP settings by upgrading your project to the Pro plan."
|
||||
className="mt-4"
|
||||
/>
|
||||
<UnlockFeatureByUpgrading message="Unlock SMTP settings by upgrading your project to the Pro plan." />
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
} from '@/components/workspace';
|
||||
import { WorkspaceInvoices } from '@/components/workspace/WorkspaceInvoices';
|
||||
import WorkspacePaymentMethods from '@/components/workspace/WorkspacePaymentMethods';
|
||||
import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWorkspacesAndApplications';
|
||||
import useNotFoundRedirect from '@/hooks/useNotFoundRedirect';
|
||||
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
|
||||
import { NextSeo } from 'next-seo';
|
||||
@@ -17,7 +16,6 @@ import type { ReactElement } from 'react';
|
||||
export default function WorkspaceDetailsPage() {
|
||||
const { currentWorkspace, loading } = useCurrentWorkspaceAndProject();
|
||||
|
||||
useGetAllUserWorkspacesAndApplications(false);
|
||||
useNotFoundRedirect();
|
||||
|
||||
if (!currentWorkspace || loading) {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import DialogProvider from '@/components/common/DialogProvider/DialogProvider';
|
||||
import { DialogProvider } from '@/components/common/DialogProvider';
|
||||
import ErrorBoundaryFallback from '@/components/common/ErrorBoundaryFallback';
|
||||
import { ManagedUIContext } from '@/context/UIContext';
|
||||
import { UserDataProvider } from '@/context/UserDataContext';
|
||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||
import '@/styles/fonts.css';
|
||||
import '@/styles/globals.css';
|
||||
@@ -93,26 +92,24 @@ function MyApp({
|
||||
nhost={nhost}
|
||||
connectToDevTools={process.env.NEXT_PUBLIC_ENV === 'dev'}
|
||||
>
|
||||
<UserDataProvider>
|
||||
<ManagedUIContext>
|
||||
<Toaster position="bottom-center" />
|
||||
<ManagedUIContext>
|
||||
<Toaster position="bottom-center" />
|
||||
|
||||
{isPlatform && (
|
||||
<Script
|
||||
id="segment"
|
||||
dangerouslySetInnerHTML={{ __html: renderSnippet() }}
|
||||
/>
|
||||
)}
|
||||
{isPlatform && (
|
||||
<Script
|
||||
id="segment"
|
||||
dangerouslySetInnerHTML={{ __html: renderSnippet() }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ThemeProvider
|
||||
colorPreferenceStorageKey={COLOR_PREFERENCE_STORAGE_KEY}
|
||||
>
|
||||
<DialogProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</ManagedUIContext>
|
||||
</UserDataProvider>
|
||||
<ThemeProvider
|
||||
colorPreferenceStorageKey={COLOR_PREFERENCE_STORAGE_KEY}
|
||||
>
|
||||
<DialogProvider>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
</DialogProvider>
|
||||
</ThemeProvider>
|
||||
</ManagedUIContext>
|
||||
</NhostApolloProvider>
|
||||
</NhostProvider>
|
||||
</CacheProvider>
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function IndexPage() {
|
||||
stopPolling();
|
||||
}, [data?.workspaces, stopPolling]);
|
||||
|
||||
if (!data && loading) {
|
||||
if ((!data && loading) || !user) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user