Compare commits

...

126 Commits

Author SHA1 Message Date
Szilárd Dóró
899732f280 Merge pull request #1852 from nhost/changeset-release/main
chore: update versions
2023-04-21 13:40:09 +02:00
github-actions[bot]
037b566e39 chore: update versions 2023-04-21 11:23:53 +00:00
Szilárd Dóró
829f20c83c Merge pull request #1856 from nhost/renovate/vitejs-plugin-react-4.x
chore(deps): update dependency @vitejs/plugin-react to v4
2023-04-21 13:22:44 +02:00
Szilárd Dóró
f1b5a944a3 chore: add changeset 2023-04-21 11:42:44 +02:00
renovate[bot]
5ccb764ae5 chore(deps): update dependency @vitejs/plugin-react to v4 2023-04-21 09:38:43 +00:00
Szilárd Dóró
ef2b639734 Merge pull request #1839 from nhost/renovate/vueuse-core-10.x
fix(deps): update dependency @vueuse/core to v10
2023-04-21 11:36:00 +02:00
Szilárd Dóró
a5b895a827 chore: add changeset 2023-04-21 11:14:13 +02:00
renovate[bot]
b441b4bae2 fix(deps): update dependency @vueuse/core to v10 2023-04-21 09:02:30 +00:00
Szilárd Dóró
a6c67c1e4c Merge pull request #1836 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.37
2023-04-21 10:54:19 +02:00
Szilárd Dóró
7f1785ac0f chore: add changeset 2023-04-21 10:21:02 +02:00
renovate[bot]
ec74e7fe98 chore(deps): update dependency @types/react to v18.0.37 2023-04-19 12:53:55 +00:00
Szilárd Dóró
6713c198c6 Merge pull request #1833 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.9.3
2023-04-19 14:52:03 +02:00
renovate[bot]
35a6b9cf47 chore(deps): update dependency turbo to v1.9.3 2023-04-19 09:32:19 +00:00
Szilárd Dóró
79f97fad76 Merge pull request #1838 from nhost/renovate/graphql-request-6.x
fix(deps): update dependency graphql-request to v6
2023-04-19 11:29:16 +02:00
Szilárd Dóró
2faf79077d chore: add changeset 2023-04-19 11:07:46 +02:00
Szilárd Dóró
4972b6feb6 chore: sync versions, update codegen 2023-04-19 11:07:15 +02:00
Szilárd Dóró
23d5861c4c Merge pull request #1837 from rikardwissing/fix/wait-for-valid-token
Wait for valid token or sign out before establishing connection.
2023-04-19 09:31:56 +02:00
renovate[bot]
098ac5a71c fix(deps): update dependency graphql-request to v6 2023-04-18 18:54:27 +00:00
Nuno Pato
3a15329cfd Merge pull request #1849 from nhost/docs/add-compute-section
add compute section to the docs
2023-04-18 13:20:39 +00:00
Nuno Pato
c3e798aa1d asd 2023-04-18 13:15:40 +00:00
Szilárd Dóró
eec5e6a93d Merge pull request #1843 from nhost/changeset-release/main
chore: update versions
2023-04-18 15:10:41 +02:00
Nuno Pato
d964b689cd move compute resources to position 1 2023-04-18 09:37:28 +00:00
Nuno Pato
1e080c1af5 add compute section to the docs 2023-04-18 09:35:01 +00:00
github-actions[bot]
177bba7ec0 chore: update versions 2023-04-18 07:27:56 +00:00
Szilárd Dóró
a593b45dc2 Merge pull request #1845 from nhost/chore/dashboard-update-nomenclature-compute
chore: dashboard: update nomenclature for compute
2023-04-18 09:24:46 +02:00
Szilárd Dóró
b384fb8bd8 chore: merge changesets 2023-04-17 20:58:38 +02:00
Nuno Pato
abd8620ded "Resources" -> "Compute" 2023-04-17 16:58:45 +00:00
Szilárd Dóró
e62ccdcaae Merge pull request #1844 from nhost/fix/resource-memory-limit
fix(dashboard): use correct vCPUs and memory after reset
2023-04-17 14:17:49 +02:00
Szilárd Dóró
46d01b09d6 chore: use constants 2023-04-17 13:45:59 +02:00
Szilárd Dóró
ff74e712f8 fix: use correct vCPUs and memory after reset 2023-04-17 13:38:47 +02:00
Szilárd Dóró
770794ccad Merge pull request #1709 from nhost/feat/resource-sliders
feat(dashboard): Resource Sliders
2023-04-17 13:03:04 +02:00
Szilárd Dóró
aa80d1795d chore: update initial resource ratio 2023-04-17 09:28:17 +02:00
Szilárd Dóró
eaa7720c65 fix: fix tests 2023-04-17 09:17:34 +02:00
Szilárd Dóró
7f447d1182 fix: don't break the initial UI 2023-04-17 08:43:23 +02:00
Szilárd Dóró
5d3dd84762 Merge branch 'main' into feat/resource-sliders 2023-04-17 08:36:55 +02:00
Szilárd Dóró
c625317342 Merge pull request #1841 from nhost/changeset-release/main
chore: update versions
2023-04-17 08:36:12 +02:00
Rikard Wissing
117398f5dc Add changeset 2023-04-16 15:00:50 +01:00
Rikard Wissing
4e421eb4bd Refactor a bit 2023-04-16 14:55:51 +01:00
Rikard Wissing
771447b089 Remove log 2023-04-16 14:52:35 +01:00
github-actions[bot]
8ab75a4146 chore: update versions 2023-04-14 09:54:44 +00:00
Szilárd Dóró
607f465616 Merge pull request #1840 from nhost/chore/use-dialog-hook
chore(dashboard): unify payment dialog management
2023-04-14 11:50:15 +02:00
Szilárd Dóró
668c877130 chore: add changeset 2023-04-14 11:17:32 +02:00
Szilárd Dóró
4bd870eb96 chore: relocate BillingPaymentMethodForm 2023-04-14 11:15:58 +02:00
Szilárd Dóró
39b3161d91 fix: use up-to-date card information 2023-04-14 10:31:27 +02:00
Szilárd Dóró
ae090a6585 chore: unify modal management for payments 2023-04-13 16:53:30 +02:00
Rikard Wissing
be4831ae62 Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-13 15:10:14 +02:00
Szilárd Dóró
4fb0c18c32 Merge branch 'main' into feat/resource-sliders 2023-04-13 14:56:03 +02:00
Szilárd Dóró
22cdd7f8d7 Merge pull request #1834 from nhost/changeset-release/main
chore: update versions
2023-04-13 14:38:10 +02:00
github-actions[bot]
f3a91a1f76 chore: update versions 2023-04-13 11:09:10 +00:00
Szilárd Dóró
1e9b92fcf8 Merge pull request #1835 from nhost/chore/remove-user-context
chore(dashboard): cleanup unused code
2023-04-13 13:07:30 +02:00
Szilárd Dóró
6cc56066c2 chore: update changeset 2023-04-13 11:44:38 +02:00
Rikard Wissing
99e80cea44 Wait for valid token 2023-04-12 23:45:38 +02:00
Szilárd Dóró
f2f1c01e3b chore: refactor memory steps 2023-04-12 17:43:44 +02:00
Szilárd Dóró
2c0f98e85c Merge branch 'main' into feat/resource-sliders 2023-04-12 14:43:12 +02:00
Szilárd Dóró
a3ad84925c chore: cleanup additional GraphQL operations 2023-04-12 14:36:11 +02:00
Szilárd Dóró
b8611b6a1c chore: cleanup unused GraphQL operations 2023-04-12 14:25:41 +02:00
Szilárd Dóró
a0e3030005 chore: cleanup UIContext 2023-04-12 14:01:41 +02:00
Szilárd Dóró
0cf1f1d938 Merge branch 'main' into chore/remove-user-context 2023-04-12 13:32:25 +02:00
Szilárd Dóró
88f026066f Merge pull request #1830 from rikardwissing/patch-1
Add generateLinks instead of link and onError args
2023-04-12 13:26:13 +02:00
Szilárd Dóró
185bef878d fix: accommodate dashboard test 2023-04-12 11:56:03 +02:00
Szilárd Dóró
a1c7b00e74 chore: add changeset 2023-04-12 11:55:29 +02:00
Szilárd Dóró
6da4562e79 chore: format code 2023-04-12 11:55:22 +02:00
Szilárd Dóró
e44cfcb2f2 Merge branch 'patch-1' of https://github.com/rikardwissing/nhost into pr/1830 2023-04-12 11:50:32 +02:00
Rikard Wissing
23fabaf8a6 Check for undefined 2023-04-12 11:48:48 +02:00
Szilárd Dóró
f4dca9836f fix: block UI when user is not available 2023-04-12 11:34:47 +02:00
Szilárd Dóró
f2704ea149 chore: improve query refetch 2023-04-12 11:30:34 +02:00
Szilárd Dóró
dd1b053212 fix: don't break provisioning page 2023-04-12 10:46:37 +02:00
Szilárd Dóró
d4ccc65655 chore: add changeset 2023-04-12 10:41:23 +02:00
Szilárd Dóró
2c2570fc82 chore: cleanup unused hooks 2023-04-12 10:40:49 +02:00
Rikard Wissing
a60f26966b Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-12 09:52:19 +02:00
Rikard Wissing
a988de2d61 Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-12 09:51:58 +02:00
Rikard Wissing
de54ca460e Update integrations/apollo/src/index.ts
Co-authored-by: Szilárd Dóró <doroszilard@gmail.com>
2023-04-12 09:51:48 +02:00
Szilárd Dóró
afdffab743 Merge pull request #1831 from nhost/fix/functions-response
fix(nhost-js): don't suppress error messages
2023-04-12 09:46:02 +02:00
Szilárd Dóró
4c61520397 chore: add changeset 2023-04-12 09:09:09 +02:00
Szilárd Dóró
f02cd444d5 fix: don't break builds 2023-04-11 17:38:10 +02:00
Szilárd Dóró
7f45a51aca fix: don't break build 2023-04-11 17:30:21 +02:00
Szilárd Dóró
08e70b9df9 fix: don't suppress error messages 2023-04-11 17:21:40 +02:00
Szilárd Dóró
20a83362ee fix: don't break builds 2023-04-11 15:28:23 +02:00
Szilárd Dóró
20b800c3e4 Merge branch 'main' into feat/resource-sliders 2023-04-11 15:24:28 +02:00
Szilárd Dóró
bfaa5b4c4a Merge branch 'main' into pr/1830 2023-04-11 14:57:39 +02:00
Rikard Wissing
a1a00b33ad Make backwards compatible
There is a behavioral change because of how customLink was implemented before. But I think this is the intended behavior.
2023-04-11 11:49:24 +02:00
Rikard Wissing
a269f4ca3f Add generateLinks instead of link and onError args
Breaking change, but makes it more versatile.
Not tested
2023-04-11 11:16:15 +02:00
Szilárd Dóró
baaa510309 fix: add price to GQL 2023-03-31 15:28:59 +02:00
Szilárd Dóró
a84aa5ad68 Merge branch 'main' into feat/resource-sliders 2023-03-31 15:19:09 +02:00
Szilárd Dóró
4191b933c9 Merge branch 'main' into feat/resource-sliders 2023-03-24 10:57:01 +01:00
Szilárd Dóró
cf2264ce1d Merge branch 'main' into feat/resource-sliders 2023-03-20 16:25:34 +01:00
Szilárd Dóró
02dd9dd8c0 Merge branch 'main' into feat/resource-sliders 2023-03-20 12:41:38 +01:00
Szilárd Dóró
d4ff25df0f fix: adjust warning color 2023-03-10 14:19:10 +01:00
Szilárd Dóró
3d74374780 fix: update codegen 2023-03-09 10:22:43 +01:00
Szilárd Dóró
7063af678c Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-09 10:14:33 +01:00
Szilárd Dóró
2b44a1cf27 chore: add tests, fix flaky tests
- fix a UI glitch where values jumped after reset
2023-03-08 16:33:02 +01:00
Szilárd Dóró
c4f60b3645 feat: fetch plan independently 2023-03-08 15:44:30 +01:00
Szilárd Dóró
f86f658aa5 feat: improve upgrade / downgrade experience 2023-03-08 13:54:35 +01:00
Szilárd Dóró
bd02bd3f3e fix: cpu -> vcpu and selected -> available 2023-03-08 13:41:50 +01:00
Szilárd Dóró
a133faa797 chore: added submission tests 2023-03-08 11:24:17 +01:00
Szilárd Dóró
bb0269691d Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-08 10:23:48 +01:00
Szilárd Dóró
8d6171d22d chore: move test-related stuff 2023-03-07 17:23:09 +01:00
Szilárd Dóró
fff178d79f fix: fix unit tests 2023-03-07 16:59:15 +01:00
Szilárd Dóró
5e5e454ae7 chore: remove inputs from total resources 2023-03-07 16:34:40 +01:00
Szilárd Dóró
ce005f6d9e Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-07 15:37:42 +01:00
Szilárd Dóró
85889ee882 chore: add changeset 2023-03-07 10:23:33 +01:00
Szilárd Dóró
351873059e fix: use correct colors in light mode 2023-03-07 10:09:00 +01:00
Szilárd Dóró
8ccfc10522 fix: fix inner slider behaviour 2023-03-07 10:05:25 +01:00
Szilárd Dóró
82b02ca70b feat: improve slider appearance 2023-03-07 09:12:28 +01:00
Szilárd Dóró
14fc132040 fix: show footer when downgrading 2023-03-06 17:02:54 +01:00
Szilárd Dóró
a35da349ed fix: fix tests and prevent error when plan is missing 2023-03-06 16:48:43 +01:00
Szilárd Dóró
302e1d9d33 feat: retrieve plan info from the project 2023-03-06 16:18:42 +01:00
Szilárd Dóró
0db40184e8 fix: show values correctly after saving resources 2023-03-06 15:38:07 +01:00
Szilárd Dóró
d38649494e chore: added tests 2023-03-06 15:14:30 +01:00
Szilárd Dóró
5f22f1b5e5 chore: restructure to support tests
- added network requests to retrieve computation info
2023-03-06 14:13:48 +01:00
Szilárd Dóró
494f93a4bf chore: change terminology RAM -> Memory 2023-03-06 10:28:40 +01:00
Szilárd Dóró
84c8af232c fix: don't nest p in p 2023-03-03 16:54:21 +01:00
Szilárd Dóró
7f101d54da feat: add pricing placeholders 2023-03-03 16:53:28 +01:00
Szilárd Dóró
75b497412e fix: reset error, fix RAM label 2023-03-03 16:16:09 +01:00
Szilárd Dóró
5bd774afbb chore: improve readability 2023-03-03 16:11:54 +01:00
Szilárd Dóró
cdfe203fe4 Merge remote-tracking branch 'origin/main' into feat/resource-sliders 2023-03-03 15:37:45 +01:00
Szilárd Dóró
4c7d32e944 fix: resource labels 2023-03-03 15:35:17 +01:00
Szilárd Dóró
447c622fc0 fix: remove unnecessary inputs from services 2023-03-03 15:33:01 +01:00
Szilárd Dóró
03f22aed72 chore: improved resource constants 2023-03-03 15:21:15 +01:00
Szilárd Dóró
ede5abf2ac fix: use correct width for inner slider rail 2023-03-03 15:09:33 +01:00
Szilárd Dóró
0bdd1d0e0c fix: fixed top slider vlaidation 2023-03-03 14:19:08 +01:00
Szilárd Dóró
61de7b21fd feat: improve slider rails 2023-03-03 14:00:57 +01:00
Szilárd Dóró
4b6ead1b17 feat: add validation to top slider 2023-03-03 13:18:57 +01:00
Szilárd Dóró
0b193e6310 feat: create form fragments 2023-03-03 12:15:29 +01:00
Szilárd Dóró
b21a5403fe feat: add sections for services, improve slider 2023-03-03 11:15:01 +01:00
Szilárd Dóró
e8320be941 feat: initial Resource page code 2023-03-02 16:23:23 +01:00
146 changed files with 5703 additions and 3187 deletions

View File

@@ -81,7 +81,7 @@ module.exports = {
},
{
group: ['@testing-library/react*'],
message: 'Please use @/utils/testUtils instead.',
message: 'Please use @/tests/testUtils instead.',
},
],
},

View File

@@ -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

View File

@@ -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",

View File

@@ -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('/');

View File

@@ -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',
},
});

View File

@@ -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} />;
}

View File

@@ -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();

View File

@@ -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,
},
});
}}

View File

@@ -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,

View File

@@ -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';

View File

@@ -22,7 +22,7 @@ export interface OpenDialogOptions {
/**
* Title of the dialog.
*/
title: ReactNode;
title?: ReactNode;
/**
* Component to render inside the dialog skeleton.
*/

View File

@@ -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}>

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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

View File

@@ -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({

View File

@@ -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(() => {

View File

@@ -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({

View File

@@ -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,
},
});
}}

View File

@@ -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>

View File

@@ -128,6 +128,13 @@ export default function SettingsSidebar({
>
General
</SettingsNavLink>
<SettingsNavLink
href="/resources"
exact={false}
onClick={handleSelect}
>
Compute Resources
</SettingsNavLink>
{isK8SPostgresEnabledInCurrentEnvironment && (
<SettingsNavLink
href="/database"

View File

@@ -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',

View File

@@ -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',

View File

@@ -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&apos;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>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ResourcesConfirmationDialog';
export { default } from './ResourcesConfirmationDialog';

View File

@@ -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();
});

View File

@@ -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&apos;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>
);
}

View File

@@ -0,0 +1 @@
export { default } from './ResourcesForm';

View File

@@ -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>
);
}

View File

@@ -0,0 +1,2 @@
export * from './ServiceResourcesFormFragment';
export { default } from './ServiceResourcesFormFragment';

View File

@@ -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&apos;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&apos;re All Set</strong>
<p>
You have successfully allocated all the available vCPUs and
Memory.
</p>
</>
)}
</Alert>
</Box>
</Box>
);
}

View File

@@ -0,0 +1 @@
export { default } from './TotalResourcesFormFragment';

View File

@@ -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&apos;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&apos;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>
);
}

View File

@@ -1 +0,0 @@
export { default } from './ProvidersUpdatedAlert';

View File

@@ -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' },
]}

View File

@@ -42,7 +42,7 @@ function AlertDialog({
}}
{...props}
>
{!hideTitle && (
{!hideTitle && !!title && (
<Dialog.Title {...titleProps} id="alert-dialog-title">
{title}
</Dialog.Title>

View File

@@ -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)}`,
},

View File

@@ -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),

View File

@@ -16,7 +16,7 @@ export interface CommonDialogProps
/**
* The title of the dialog.
*/
title: ReactNode;
title?: ReactNode;
/**
* The message to display in the dialog.
*/

View 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);

View 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>
);
};
}

View File

@@ -0,0 +1,2 @@
export * from './Slider';
export { default } from './Slider';

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
export * from './BillingPaymentMethodForm';
export { default as BillingPaymentMethodForm } from './BillingPaymentMethodForm';

View File

@@ -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>
);

View File

@@ -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&apos;t remove this workspace because you have apps
running. Remove all apps first.
You can&apos;t delete this workspace because you have
projects running. Delete all projects first.
</Text>
)}
</Dropdown.Item>

View File

@@ -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>

View File

@@ -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} />;

View File

@@ -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;
};

View File

@@ -0,0 +1 @@
export { default as prettifyMemory } from './prettifyMemory';

View File

@@ -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,
});
}

View File

@@ -0,0 +1 @@
export { default as prettifyVCPU } from './prettifyVCPU';

View File

@@ -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;
}

View File

@@ -0,0 +1 @@
export * from './resourceSettingsValidationSchema';

View File

@@ -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
>;

View File

@@ -1,12 +0,0 @@
query getAllAppsWhere($where: apps_bool_exp!) {
apps(where: $where) {
id
name
slug
workspace {
id
name
slug
}
}
}

View File

@@ -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
}
}

View File

@@ -1,8 +0,0 @@
query getApps {
apps(order_by: { createdAt: desc }) {
id
slug
name
subdomain
}
}

View File

@@ -1,10 +0,0 @@
query getAppProvisionStatus($workspace: String!, $slug: String!) {
apps(
where: { workspace: { slug: { _eq: $workspace } }, slug: { _eq: $slug } }
) {
id
isProvisioned
subdomain
createdAt
}
}

View File

@@ -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
}
}

View File

@@ -1,5 +0,0 @@
mutation updateApp($id: uuid!, $app: apps_set_input!) {
updateApp(pk_columns: { id: $id }, _set: $app) {
id
}
}

View File

@@ -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 } }

View File

@@ -1,5 +0,0 @@
mutation insertFeatureFlag($flag: featureFlags_insert_input!) {
insertFeatureFlag(object: $flag){
id
}
}

View File

@@ -1,5 +0,0 @@
mutation deleteFiles($fileIds: [uuid!]!) {
deleteFiles(where: { id: { _in: $fileIds } }) {
affected_rows
}
}

View File

@@ -36,6 +36,7 @@ fragment Project on apps {
plan {
id
name
price
isFree
}
githubRepository {

View File

@@ -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
}
}

View File

@@ -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
}
}
}

View File

@@ -1,10 +0,0 @@
query getRemoteAppFilesUsage {
filesAggregate {
aggregate {
count
sum {
size
}
}
}
}

View File

@@ -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
}
}

View File

@@ -1,5 +0,0 @@
mutation confirmProvidersUpdated($id: uuid!) {
updateApp(pk_columns: { id: $id }, _set: { providersUpdated: true }) {
id
}
}

View File

@@ -1,20 +0,0 @@
query getAllUserData {
workspaceMembers {
id
workspace {
id
name
creatorUserId
apps {
id
name
subdomain
config(resolve: true) {
hasura {
adminSecret
}
}
}
}
}
}

View File

@@ -1,6 +0,0 @@
query GetAvatar($userId: uuid!) {
user(id: $userId) {
id
avatarUrl
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -1,20 +0,0 @@
query getUserAllWorkspaces {
workspaceMembers {
id
userId
workspace {
id
name
slug
apps {
id
name
plan {
id
name
}
slug
}
}
}
}

View File

@@ -1,14 +0,0 @@
query getAppsByWorkspace($workspace_id: uuid!) {
workspace(id: $workspace_id) {
id
name
slug
apps {
name
plan {
id
name
}
}
}
}

View File

@@ -1,5 +0,0 @@
query getWorkspaceInvoices($id: uuid!) {
workspace(id: $id) {
id
}
}

View File

@@ -1,15 +0,0 @@
query getWorkspaceSettings($id: uuid!) {
workspace(id: $id) {
id
name
addressLine1
addressLine2
addressPostalCode
addressPostalCode
addressCity
addressState
addressCountryCode
companyName
email
}
}

View File

@@ -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
}
}

View File

@@ -1,16 +0,0 @@
query GetWorkspacesAppsById($workspaceId: uuid!) {
workspace(id: $workspaceId) {
id
slug
apps {
id
name
slug
updatedAt
plan {
id
name
}
}
}
}

View File

@@ -1,9 +0,0 @@
query getWorkspaces {
workspaces(order_by: { name: asc }) {
id
createdAt
name
slug
creatorUserId
}
}

View File

@@ -0,0 +1 @@
export { default } from './useProPlan';

View 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 };
}

View File

@@ -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],
});
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 };
}

View File

@@ -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;

View File

@@ -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,
};
}

View File

@@ -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:

View File

@@ -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,
},
});
},

View File

@@ -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,
},

View File

@@ -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>
);
};

View File

@@ -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 />

View File

@@ -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>
);
}

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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