Compare commits

..

256 Commits

Author SHA1 Message Date
Szilárd Dóró
a8a20cf5e2 Merge pull request #1820 from nhost/changeset-release/main 2023-04-06 14:37:59 +02:00
github-actions[bot]
2f84bf3251 chore: update versions 2023-04-06 12:22:20 +00:00
Szilárd Dóró
368e0371e9 Merge pull request #1818 from nhost/feat/improved-error-state
feat(dashboard): improved error state
2023-04-06 14:21:08 +02:00
Szilárd Dóró
adb5209133 Merge branch 'main' into feat/improved-error-state 2023-04-06 14:00:07 +02:00
Szilárd Dóró
63bf405cdd Merge pull request #1800 from Glenas7/docs/local-development-guide
Update local URLs to new format
2023-04-06 13:48:40 +02:00
Szilárd Dóró
d613d66a0a Merge pull request #1819 from nhost/fix/revert-i18n-lib
fix(dashboard): revert i18n library to improve performance
2023-04-06 13:36:21 +02:00
Szilárd Dóró
e7cb5070cd fix: use updated URLs everywhere 2023-04-06 13:28:55 +02:00
Szilárd Dóró
ee50051802 chore: remove unused next-translate-plugin 2023-04-06 13:08:53 +02:00
Szilárd Dóró
20e7bb4747 fix: remove translation 2023-04-06 13:08:06 +02:00
Szilárd Dóró
ba0d57ee91 fix: revert i18n library 2023-04-06 12:56:53 +02:00
Szilárd Dóró
98093c9023 Update dashboard/locales/en/overview.json
Co-authored-by: Nuno Pato <nunopato@gmail.com>
2023-04-06 12:28:08 +02:00
Szilárd Dóró
2fda299736 fix comment 2023-04-06 12:14:02 +02:00
Szilárd Dóró
3328ed059e chore: add changeset 2023-04-06 11:42:20 +02:00
Szilárd Dóró
cfb7199b79 feat: show error message on project overview 2023-04-06 11:41:09 +02:00
Szilárd Dóró
1ad4bfe815 fix readme 2023-04-06 09:26:44 +02:00
Szilárd Dóró
25859fc421 Merge pull request #1812 from nhost/changeset-release/main
chore: update versions
2023-04-06 09:22:15 +02:00
github-actions[bot]
5a9e7a43c8 chore: update versions 2023-04-06 06:45:13 +00:00
Szilárd Dóró
2739ff90c4 Merge pull request #1811 from nhost/feat/metrics
feat(dashboard): Show Metrics
2023-04-06 08:43:45 +02:00
Szilárd Dóró
2a4623c582 Merge pull request #1815 from nhost/renovate/peter-evans-create-pull-request-5.x
chore(deps): update peter-evans/create-pull-request action to v5
2023-04-05 11:11:26 +02:00
Szilárd Dóró
19b7835d92 fix: remove empty test file 2023-04-05 11:04:04 +02:00
Szilárd Dóró
efbd272298 fix: use separator for sizes 2023-04-05 10:32:52 +02:00
Szilárd Dóró
98546d24e1 chore: improve number formatting 2023-04-05 10:28:59 +02:00
Szilárd Dóró
fe2c0cf81f Merge branch 'main' into feat/metrics 2023-04-05 09:30:37 +02:00
renovate[bot]
b28a04c48e chore(deps): update peter-evans/create-pull-request action to v5 2023-04-05 07:29:01 +00:00
Szilárd Dóró
a014913523 Merge pull request #1810 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.33
2023-04-05 09:28:22 +02:00
Szilárd Dóró
706c9dc3fb chore: add changeset 2023-04-04 18:45:17 +02:00
Szilárd Dóró
fe08faad4a feat: introduce i18n lib 2023-04-04 18:34:26 +02:00
Szilárd Dóró
6719ce92ea feat: add tooltip to metrics 2023-04-04 14:32:35 +02:00
Szilárd Dóró
52c6f09bdd chore: simplify pending UI 2023-04-04 13:57:36 +02:00
Szilárd Dóró
f337a19875 chore: add test 2023-04-04 13:42:38 +02:00
renovate[bot]
d62c909901 chore(deps): update dependency @types/react to v18.0.33 2023-04-04 11:02:39 +00:00
Szilárd Dóró
99f8f6b370 chore: add changeset 2023-04-04 13:01:02 +02:00
Szilárd Dóró
644d94a175 Merge pull request #1803 from nhost/renovate/next-seo-6.x
fix(deps): update dependency next-seo to v6
2023-04-04 13:00:36 +02:00
Szilárd Dóró
05ab111aa4 chore: remove postgres usage 2023-04-04 12:59:31 +02:00
Szilárd Dóró
64cf0acd4a Merge pull request #1792 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.8.8
2023-04-04 11:57:19 +02:00
Szilárd Dóró
3d5d530555 chore: improve usage panel 2023-04-04 11:51:06 +02:00
Szilárd Dóró
5e0920ba7c chore: add changeset 2023-04-04 10:12:36 +02:00
Szilárd Dóró
9bf6c3b8c4 feat: add egress volume to metrics 2023-04-03 21:21:36 +02:00
Szilárd Dóró
b25a223d90 feat: add metrics to the project overview 2023-04-03 17:33:58 +02:00
renovate[bot]
748aa443f4 fix(deps): update dependency next-seo to v6 2023-04-03 14:38:59 +00:00
renovate[bot]
684123e5d6 chore(deps): update dependency turbo to v1.8.8 2023-04-03 14:37:37 +00:00
Szilárd Dóró
fa045eed15 Merge pull request #1808 from nhost/changeset-release/main
chore: update versions
2023-04-03 16:35:43 +02:00
github-actions[bot]
61c0583b6d chore: update versions 2023-04-03 13:56:32 +00:00
Szilárd Dóró
1343a6f252 Merge pull request #1806 from nhost/fix/failed-subscriptions
fix: don't open unnecessary connections
2023-04-03 15:55:27 +02:00
Szilárd Dóró
0d73e87a83 fix: don't open unnecessary connections 2023-04-03 15:14:28 +02:00
Szilárd Dóró
1ee0d332bf Merge pull request #1797 from nhost/changeset-release/main
chore: update versions
2023-04-03 13:50:27 +02:00
github-actions[bot]
130ce49c76 chore: update versions 2023-04-03 10:06:30 +00:00
Szilárd Dóró
6be6d6475a Merge pull request #1804 from nhost/fix/date-input
fix(dashboard): don't break logs page
2023-04-03 11:58:37 +02:00
Szilárd Dóró
177b146b93 Merge pull request #1799 from nhost/renovate/urql-4.x
chore(deps): update dependency urql to v4
2023-04-03 11:39:47 +02:00
Szilárd Dóró
3cb673000a fix: don't break logs page 2023-04-03 11:38:34 +02:00
Szilárd Dóró
09cf5d4b39 chore: add changeset 2023-04-03 10:07:16 +02:00
Szilárd Dóró
48c0061a0b Merge pull request #1772 from wollerman/patch-2
Update serverless-functions to link to event trigger docs
2023-04-03 09:47:01 +02:00
Szilárd Dóró
0795d1c6a1 chore: move new text 2023-04-03 09:34:02 +02:00
renovate[bot]
45a23dd1bf chore(deps): update dependency urql to v4 2023-04-03 07:30:32 +00:00
Szilárd Dóró
bb8e3454df Merge pull request #1790 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.32
2023-04-03 09:28:01 +02:00
Szilárd Dóró
6a290bb297 chore: add changeset 2023-04-03 09:10:03 +02:00
renovate[bot]
80baec7356 chore(deps): update dependency @types/react to v18.0.32 2023-04-02 09:44:20 +00:00
Glenas7
feb195fd65 Update local URLs to new format
With the change of local URLs to the subdomain format with "local" as subdiomain and no region, I believe these URLs should be updated here.
2023-04-01 11:19:57 +02:00
Szilárd Dóró
8e43297564 Merge pull request #1798 from nhost/feat/project-creator
feat(dashboard): show project creator
2023-03-31 14:49:16 +02:00
Szilárd Dóró
bb8eb9e387 fix: fix assertion in test 2023-03-31 13:55:05 +02:00
Szilárd Dóró
5b0dc6cb19 fix: use optional chaining in overview header 2023-03-31 13:45:58 +02:00
Szilárd Dóró
826112afd0 fix: don't show upgrade button for pro projects 2023-03-31 13:32:16 +02:00
Szilárd Dóró
97105c390d chore: remove test file 2023-03-31 13:26:59 +02:00
Szilárd Dóró
8e3707ff2c Merge branch 'main' into feat/project-creator 2023-03-31 13:25:32 +02:00
Szilárd Dóró
7453bf3b6a chore: add changeset 2023-03-31 13:25:25 +02:00
Szilárd Dóró
bd739383d2 Merge pull request #1796 from nhost/chore/improved-auth-tests
chore(tests): improve auth page tests
2023-03-31 13:19:44 +02:00
Szilárd Dóró
f75e2e41db chore: prefix email addresses 2023-03-31 10:48:54 +02:00
Szilárd Dóró
7328491be0 feat: add relative time to creator info 2023-03-30 20:37:53 +02:00
Szilárd Dóró
11b4d12f12 Merge pull request #1794 from nhost/changeset-release/main
chore: update versions
2023-03-30 19:56:33 +02:00
Szilárd Dóró
6c24d56b1d fix: remove test.only 2023-03-30 17:41:14 +02:00
Szilárd Dóró
0a523f4b45 feat: add project creator to overviews 2023-03-30 17:37:21 +02:00
Szilárd Dóró
12301e6551 fix: use correct @nhost/apollo version 2023-03-30 15:57:43 +02:00
Szilárd Dóró
8440d0389e chore: restructure auth tests 2023-03-30 15:55:58 +02:00
Szilárd Dóró
c166dad0f8 chore: add changeset 2023-03-30 13:49:14 +02:00
Szilárd Dóró
e31d39b3d2 feat: incorporate global setup in projects 2023-03-30 13:44:34 +02:00
Szilárd Dóró
090f9cef86 chore: extend user management tests 2023-03-30 13:35:06 +02:00
github-actions[bot]
74e52cac2d chore: update versions 2023-03-30 09:07:41 +00:00
Szilárd Dóró
f17823760a Merge pull request #1795 from nhost/fix/presigned-urls
fix: don't alter URLs when no transformation parameters are available
2023-03-30 11:06:32 +02:00
Szilárd Dóró
bb8803a1e3 fix: don't alter URLs 2023-03-30 10:41:57 +02:00
Szilárd Dóró
b846291331 Merge pull request #1793 from nhost/fix/export-issue
fix: don't use conflicting names
2023-03-30 10:07:24 +02:00
Szilárd Dóró
2b2fb94f00 chore: add type checking step 2023-03-30 09:42:23 +02:00
Szilárd Dóró
551760c4f0 fix: don't break builds 2023-03-30 09:37:39 +02:00
Szilárd Dóró
5ae5a8e77d fix: don't break builds 2023-03-30 09:31:54 +02:00
Szilárd Dóró
56aae0c964 fix: don't break builds 2023-03-30 09:28:34 +02:00
Szilárd Dóró
a0e093d77b fix: don't use conflicting names 2023-03-30 09:16:30 +02:00
Szilárd Dóró
5e82e1b3da Merge pull request #1784 from nhost/changeset-release/main
chore: update versions
2023-03-29 09:20:48 +02:00
github-actions[bot]
e618b705e7 chore: update versions 2023-03-28 15:52:47 +00:00
Szilárd Dóró
a232c9f0f6 Merge pull request #1789 from nhost/fix/azuread-description
fix(dashboard): use correct description for Azure AD
2023-03-28 17:50:51 +02:00
Szilárd Dóró
bf4644ea10 fix: use correct description for Azure AD 2023-03-28 16:52:54 +02:00
Szilárd Dóró
0aca907ea4 Merge pull request #1788 from nhost/fix/azuread-provider-name
fix: correct typos in Azure AD
2023-03-28 16:25:59 +02:00
Szilárd Dóró
394f4c4174 fix: correct typos in Azure AD 2023-03-28 16:25:26 +02:00
Szilárd Dóró
8fef08a150 Merge pull request #1786 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.8.6
2023-03-28 16:16:57 +02:00
Szilárd Dóró
1bd2c37301 chore: bump turbo in the Dockerfile 2023-03-28 15:54:37 +02:00
renovate[bot]
5cdb70bd81 chore(deps): update dependency turbo to v1.8.6 2023-03-28 12:01:36 +00:00
Szilárd Dóró
1a5f80e1b6 Merge pull request #1785 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.30
2023-03-28 13:59:29 +02:00
Szilárd Dóró
59e0cb00c5 Merge pull request #1787 from nhost/feat/azuread-provider 2023-03-28 12:25:42 +02:00
Szilárd Dóró
406b0f2cb7 Merge pull request #1163 from dipakparmar/feat/dashboard-azuread-settings
feat(dashboard): add azure ad provider settings
2023-03-28 10:52:17 +02:00
Szilárd Dóró
d329b6218f chore: add changeset 2023-03-28 10:46:50 +02:00
Szilárd Dóró
335b58670e Merge branch 'renovate/react-monorepo' of https://github.com/nhost/nhost into renovate/react-monorepo 2023-03-28 10:43:08 +02:00
renovate[bot]
efa2d89067 chore(deps): update dependency @types/react to v18.0.30 2023-03-28 08:35:55 +00:00
Szilárd Dóró
77ce4bd738 Merge pull request #1783 from nhost/fix/random-words
fix(tests): avoid name collision in database tests
2023-03-28 10:33:33 +02:00
Szilárd Dóró
017adea700 chore: update comment 2023-03-28 10:04:38 +02:00
Dipak Parmar
378284faa8 chore(dashboard): remove docs and title for now from azuread component
Signed-off-by: Dipak Parmar <hi@dipak.tech>
2023-03-27 23:44:40 -07:00
renovate[bot]
e5e2d114b1 chore(deps): update dependency @types/react to v18.0.30 2023-03-27 19:03:37 +00:00
Szilárd Dóró
5e3dbdeb7d Merge pull request #1781 from nhost/renovate/react-monorepo
chore(deps): update dependency @types/react to v18.0.29
2023-03-27 20:55:47 +02:00
Szilárd Dóró
98b777491a fix: improve flaky tests 2023-03-27 18:13:10 +02:00
Szilárd Dóró
71de870cb0 fix: use admin secret as env var 2023-03-27 17:29:09 +02:00
Szilárd Dóró
74d4deba28 feat: cleanup public schema after tests 2023-03-27 17:00:07 +02:00
Szilárd Dóró
cb248f0d30 chore: add changeset 2023-03-27 15:44:08 +02:00
Szilárd Dóró
09e4f1eb34 fix: avoid duplicate table names in tests 2023-03-27 15:16:40 +02:00
Szilárd Dóró
19818e2b59 Merge pull request #1777 from nhost/changeset-release/main
chore: update versions
2023-03-27 12:03:16 +02:00
Szilárd Dóró
6e1f03eaee chore: accomodate changes to API 2023-03-27 11:57:24 +02:00
github-actions[bot]
b3eeec82ef chore: update versions 2023-03-27 09:38:55 +00:00
Szilárd Dóró
34ff254696 Merge pull request #1782 from nhost/renovate/sharp-0.x
fix(deps): update dependency sharp to ^0.32.0
2023-03-27 11:37:33 +02:00
Szilárd Dóró
867c807699 chore: add changeset 2023-03-27 11:21:42 +02:00
Szilárd Dóró
1c4806bf51 chore: add changeset 2023-03-27 11:17:41 +02:00
renovate[bot]
2fb82ec97d fix(deps): update dependency sharp to ^0.32.0 2023-03-27 07:50:45 +00:00
renovate[bot]
d0673d7825 chore(deps): update dependency @types/react to v18.0.29 2023-03-27 07:50:19 +00:00
Dipak Parmar
106f23dcfa fixdashboard-settings): remove extra whitespace azuread provider import in settings
Signed-off-by: Dipak Parmar <hi@dipak.tech>
2023-03-27 00:48:56 -07:00
Szilárd Dóró
0c994a9651 Merge pull request #1779 from nhost/renovate/pnpm-find-workspace-dir-6.x
fix(deps): update dependency @pnpm/find-workspace-dir to v6
2023-03-27 09:48:14 +02:00
Dipak Parmar
83ef755822 feat(dashboard-settings): update azuread provider settings component
Signed-off-by: Dipak Parmar <hi@dipak.tech>
2023-03-27 00:47:09 -07:00
Dipak Parmar
b7703ffd70 feat(graphql): add azuread to signinmethods query
Signed-off-by: Dipak Parmar <hi@dipak.tech>
2023-03-27 00:46:30 -07:00
Szilárd Dóró
4713cecfc2 chore: add changeset 2023-03-27 09:26:44 +02:00
Dipak Parmar
340ea5b115 chore: merge branch 'main' into feat/dashboard-azuread-settings
* main: (1322 commits)
  chore(ci): adjust preview fetcher
  chore: add changeset
  fix: fetch valid previews only
  fix: use correct Vercel token
  fix: use staging project ID
  chore: use dynamic test URL
  fix(deps): update docusaurus monorepo to v2.4.0
  chore(hasura-storage-js): improve presignedUrl test
  fix: remove test.only call
  chore: add tests for table deletion
  chore: update versions
  fix: potential subscription fix
  Fix import in docs
  fix: remove `test.only` call
  chore: add remaining table creation tests
  chore: add foreign key constraint test
  chore: add extra database UI tests
  chore: restructure tests, add basic table creation test
  chore: update versions
  chore: add changeset
  ...

Signed-off-by: Dipak Parmar <hi@dipak.tech>
2023-03-26 19:16:40 -07:00
renovate[bot]
f79eebadbf fix(deps): update dependency @pnpm/find-workspace-dir to v6 2023-03-24 21:30:22 +00:00
Szilárd Dóró
ac174b5e51 Merge pull request #1780 from nhost/chore/vercel-preview-fetcher 2023-03-24 17:07:43 +01:00
Szilárd Dóró
dc9ddfc9ae chore(ci): adjust preview fetcher 2023-03-24 16:30:29 +01:00
Szilárd Dóró
3bdd9f570c Merge pull request #1773 from nhost/chore/dashboard-delete-table-tests
chore(dashboard): tests for table deletion
2023-03-24 15:52:25 +01:00
Szilárd Dóró
94477be998 Merge pull request #1778 from nhost/chore/fetch-preview-url
chore: use dynamic test URL
2023-03-24 15:51:47 +01:00
Szilárd Dóró
568577e8ca Merge pull request #1774 from nhost/renovate/docusaurus-monorepo
fix(deps): update docusaurus monorepo to v2.4.0
2023-03-24 15:38:23 +01:00
Szilárd Dóró
e93b06ab8f chore: add changeset 2023-03-24 15:37:08 +01:00
Szilárd Dóró
c75bf46ba1 fix: fetch valid previews only 2023-03-24 15:24:36 +01:00
Szilárd Dóró
63a1fd09b5 fix: use correct Vercel token 2023-03-24 15:09:42 +01:00
Szilárd Dóró
630d44ad6e fix: use staging project ID 2023-03-24 14:55:26 +01:00
Szilárd Dóró
d7db521974 chore: use dynamic test URL 2023-03-24 14:16:05 +01:00
renovate[bot]
90e4053f0a fix(deps): update docusaurus monorepo to v2.4.0 2023-03-24 09:57:19 +00:00
Szilárd Dóró
8e9d5d1b38 Merge pull request #1775 from nhost/fix/storage-sdk-tests
chore(hasura-storage-js): improve presignedUrl test
2023-03-24 10:54:51 +01:00
Szilárd Dóró
43c86fef14 chore(hasura-storage-js): improve presignedUrl test 2023-03-24 10:25:18 +01:00
Szilárd Dóró
6b97340cf4 fix: remove test.only call 2023-03-23 16:14:49 +01:00
Szilárd Dóró
1605756362 chore: add tests for table deletion 2023-03-23 16:05:21 +01:00
Szilárd Dóró
6437544384 Merge pull request #1771 from nhost/changeset-release/main
chore: update versions
2023-03-23 14:20:16 +01:00
Matt Wollerman
776eca3fb5 Update serverless-functions to link to event trigger docs 2023-03-23 09:06:20 -04:00
github-actions[bot]
b4dcd1996d chore: update versions 2023-03-23 13:01:48 +00:00
Szilárd Dóró
7fb73dbb1b Merge pull request #1770 from nhost/fix/subscription-errors
fix(apollo): retry subscriptions on error
2023-03-23 14:00:11 +01:00
Szilárd Dóró
a66b11d245 Merge pull request #1769 from st3phan/patch-1
Fix import in docs for SignedIn component
2023-03-23 13:23:35 +01:00
Szilárd Dóró
912ed76c64 fix: potential subscription fix 2023-03-23 12:30:14 +01:00
Szilárd Dóró
b47c0d1af7 Merge pull request #1765 from nhost/chore/dashboard-db-tests
chore(dashboard): tests for table creation
2023-03-23 09:36:27 +01:00
Stephan van Opstal
b97ab2be2f Fix import in docs 2023-03-22 21:46:58 +01:00
Szilárd Dóró
f12cb666ff fix: remove test.only call 2023-03-22 15:42:05 +01:00
Szilárd Dóró
c3b2b1cd02 chore: add remaining table creation tests 2023-03-22 15:40:39 +01:00
Szilárd Dóró
c0b71102d4 chore: add foreign key constraint test 2023-03-22 15:32:18 +01:00
Szilárd Dóró
5f68ae95c4 chore: add extra database UI tests 2023-03-22 15:22:49 +01:00
Szilárd Dóró
2d1b7bb292 chore: restructure tests, add basic table creation test 2023-03-22 14:57:33 +01:00
Szilárd Dóró
ee154d4eca Merge pull request #1764 from nhost/changeset-release/main
chore: update versions
2023-03-22 14:21:18 +01:00
github-actions[bot]
58ef9bbe02 chore: update versions 2023-03-22 12:49:02 +00:00
Szilárd Dóró
f3f35beefd Merge pull request #1758 from nhost/renovate/turbo-1.x
chore(deps): update dependency turbo to v1.8.5
2023-03-22 13:47:44 +01:00
Szilárd Dóró
d31330e6c0 Merge pull request #1762 from nhost/renovate/react-error-boundary-4.x
fix(deps): update dependency react-error-boundary to v4
2023-03-22 13:47:30 +01:00
Szilárd Dóró
c3dda79d95 Merge branch 'renovate/react-error-boundary-4.x' of https://github.com/nhost/nhost into renovate/react-error-boundary-4.x 2023-03-22 13:11:13 +01:00
Szilárd Dóró
7c1273725d chore: add changeset 2023-03-22 13:11:01 +01:00
renovate[bot]
70be0e1ab4 fix(deps): update dependency react-error-boundary to v4 2023-03-22 12:08:14 +00:00
renovate[bot]
4f5870cfd7 chore(deps): update dependency turbo to v1.8.5 2023-03-22 12:07:47 +00:00
Szilárd Dóró
623607476e Merge branch 'main' into renovate/react-error-boundary-4.x 2023-03-22 13:07:31 +01:00
Szilárd Dóró
1e232713d9 Merge pull request #1763 from nhost/chore/improve-e2e
chore(tests): improve E2E tests
2023-03-22 13:05:43 +01:00
Szilárd Dóró
1ed647c4e9 fix: fix lint and test jobs 2023-03-22 12:50:12 +01:00
Szilárd Dóró
b666a173b1 fix: use correct name for the build script 2023-03-22 12:15:48 +01:00
Szilárd Dóró
caba147b32 chore: add changeset 2023-03-22 12:07:44 +01:00
Szilárd Dóró
ca365fc8e7 cleanup cypress 2023-03-22 12:06:46 +01:00
Szilárd Dóró
d88cdedb26 migrated file upload e2e tests 2023-03-22 11:39:17 +01:00
Szilárd Dóró
1de08cecaf migrate password change e2e tests 2023-03-22 11:21:38 +01:00
Szilárd Dóró
47bb997036 migrate email change e2e tests 2023-03-22 11:15:20 +01:00
Szilárd Dóró
4e4d962f30 migrate apollo e2e tests 2023-03-22 11:05:46 +01:00
Szilárd Dóró
883fb82c77 migrate sign-in tests 2023-03-22 10:46:11 +01:00
Szilárd Dóró
c9f5634ac2 chore: migrate sign in e2e tests 2023-03-21 18:25:19 +01:00
renovate[bot]
6ee9a589fb fix(deps): update dependency react-error-boundary to v4 2023-03-21 17:21:51 +00:00
Szilárd Dóró
e2d733cf34 chore: migrate passwordless e2e tests 2023-03-21 16:45:19 +01:00
Szilárd Dóró
a0d7327c8d chore: migrate sign up tests 2023-03-21 16:33:56 +01:00
Szilárd Dóró
c7c8a20334 chore: port some tests to playwright 2023-03-21 16:10:37 +01:00
Szilárd Dóró
cca8de5805 Merge pull request #1760 from nhost/fix/dashboard-e2e-workflow
fix dashboard e2e workflow
2023-03-21 13:42:10 +01:00
Szilárd Dóró
8c065c42d6 fix: remove dashboard-e2e job 2023-03-21 13:19:31 +01:00
Szilárd Dóró
210af3a3e8 merge ci and e2e workflows 2023-03-21 12:52:01 +01:00
Szilárd Dóró
fbb12a8079 update workflow triggers 2023-03-21 12:44:37 +01:00
Szilárd Dóró
77692ac40e remove unnecessary job from e2e workflow 2023-03-21 11:57:49 +01:00
Szilárd Dóró
2c2a42a8e8 fix test script 2023-03-21 11:51:22 +01:00
Szilárd Dóró
a8466798a3 Merge pull request #1761 from nhost/changeset-release/main
chore: update versions
2023-03-21 11:47:58 +01:00
Szilárd Dóró
a45c0970bb pin playwright version 2023-03-21 11:46:30 +01:00
Szilárd Dóró
9bf30a1ccc fix e2e script 2023-03-21 11:35:12 +01:00
github-actions[bot]
99d3d82c72 chore: update versions 2023-03-21 10:23:53 +00:00
Szilárd Dóró
43acb3fb50 Merge pull request #1757 from nhost/renovate/hookform-resolvers-3.x
fix(deps): update dependency @hookform/resolvers to v3
2023-03-21 11:22:15 +01:00
Szilárd Dóró
ba9ef13ba3 update workflow 2023-03-21 11:21:28 +01:00
Szilárd Dóró
cea507a271 fix: install browsers before e2e tests 2023-03-21 11:07:24 +01:00
Szilárd Dóró
9130ab1230 chore(deps): bump yup and @hookform/resolvers 2023-03-21 10:58:44 +01:00
Szilárd Dóró
27acdd6f56 fix: add missing env vars 2023-03-21 10:43:55 +01:00
Szilárd Dóró
dcdacd73ec fix: fix dashboard e2e workflow 2023-03-21 10:24:10 +01:00
Szilárd Dóró
9c9966a30f Merge pull request #1759 from diecknet/patch-1
fix DevDependencies parameter for @types/express
2023-03-21 10:22:50 +01:00
Andreas Dieckmann
5a23e7a0a8 fix DevDependencies parameter for @types/express
it's `-D` not `-d`
2023-03-20 22:18:40 +01:00
renovate[bot]
47500fac39 fix(deps): update dependency @hookform/resolvers to v3 2023-03-20 18:24:31 +00:00
Szilárd Dóró
cbbf53c05b Merge pull request #1756 from nhost/feat/playwright
chore(dashboard): prepare E2E testing framework
2023-03-20 16:02:27 +01:00
Szilárd Dóró
11bd011860 fix: correct CI workflows 2023-03-20 15:02:36 +01:00
Szilárd Dóró
e3c0c47777 fix: correct tests 2023-03-20 14:03:27 +01:00
Szilárd Dóró
d825404b54 Merge branch 'main' into feat/playwright 2023-03-20 13:45:17 +01:00
Szilárd Dóró
d46d77ee71 Merge pull request #1751 from Glenas7/docs/google-oauth-guide
Added Javascript origins step to docs
2023-03-20 12:20:35 +01:00
Szilárd Dóró
a292482705 Update docs/docs/authentication/sign-in-methods/4-google.mdx 2023-03-20 12:20:27 +01:00
Szilárd Dóró
8a4ca41172 Merge pull request #1754 from nhost/changeset-release/main
chore: update versions
2023-03-20 11:33:21 +01:00
github-actions[bot]
fd3ce98600 chore: update versions 2023-03-20 10:08:17 +00:00
Szilárd Dóró
04f36a0491 Merge pull request #1669 from nhost/new-create-app-mutation
feat(dashboard): Limit Free Projects
2023-03-20 11:05:30 +01:00
Szilárd Dóró
5e2ecb4d1e Merge pull request #1749 from nhost/changeset-release/main
chore: update versions
2023-03-20 10:00:29 +01:00
Glenas7
eca9e551e8 Added Javascript origins step 2023-03-18 21:30:46 +01:00
github-actions[bot]
52ebbef762 chore: update versions 2023-03-17 15:01:14 +00:00
Szilárd Dóró
82faa4ca0a Merge pull request #1748 from nhost/fix/presigned-url-params
fix(hasura-storage-js): allow image transformation parameters in `getPresignedUrl`
2023-03-17 15:58:38 +01:00
Szilárd Dóró
d06a21764a fix unit tests 2023-03-17 15:10:15 +01:00
Szilárd Dóró
8b54d290a5 Merge pull request #1747 from nhost/changeset-release/main
chore: update versions
2023-03-17 14:51:41 +01:00
Szilárd Dóró
4cfa6bbe1e chore: update changeset 2023-03-17 14:12:48 +01:00
Szilárd Dóró
614f213e26 feat: allow image transformation parameters in getPresignedUrl 2023-03-17 14:11:17 +01:00
github-actions[bot]
4eebf51821 chore: update versions 2023-03-17 11:29:52 +00:00
Szilárd Dóró
9a52298aa7 Merge pull request #1746 from nhost/fix/data-grid-date-cell
fix(dashboard): show correct date in data grid
2023-03-17 12:28:34 +01:00
Szilárd Dóró
099eebe602 Merge pull request #1745 from nhost/fix/disable-new-users
fix(dashboard): disable new users
2023-03-17 12:20:38 +01:00
Szilárd Dóró
7cce8652e7 chore: update response message for pausing 2023-03-17 12:20:16 +01:00
Szilárd Dóró
f2e2323801 fix: refresh list when deleting app 2023-03-17 12:09:41 +01:00
Szilárd Dóró
4e16de6db2 chore: cleanup, improve error messages 2023-03-17 12:01:11 +01:00
Szilárd Dóró
798e591b1d fix: show correct date in data grid 2023-03-17 10:19:39 +01:00
Szilárd Dóró
b48bc034ca chore: add changeset 2023-03-17 10:01:26 +01:00
Szilárd Dóró
f57819230b fix: disable new users 2023-03-17 10:00:25 +01:00
Szilárd Dóró
3d8067ff7b fix: show pausing only for free projects
- improve project list
2023-03-17 09:44:02 +01:00
Szilárd Dóró
0fa4b428a9 chore: change function to string 2023-03-16 15:04:13 +01:00
Szilárd Dóró
8c5864340e fix: fix build error 2023-03-16 14:57:25 +01:00
Szilárd Dóró
c131100af9 chore: fetch free and live apps separately 2023-03-16 14:52:35 +01:00
Szilárd Dóró
e363fef8cf fix: refetch projects after delete/pause 2023-03-16 13:11:28 +01:00
Szilárd Dóró
d8072101c8 feat: added pause section to settings 2023-03-16 13:03:11 +01:00
Szilárd Dóró
afbba531a1 Merge branch 'main' into new-create-app-mutation 2023-03-16 10:28:02 +01:00
Szilárd Dóró
a9e9fc4305 chore: extend tests 2023-03-10 16:57:21 +01:00
Szilárd Dóró
c547b490e5 chore: improved overview tests 2023-03-10 11:46:12 +01:00
Szilárd Dóró
4f4449b855 Merge remote-tracking branch 'origin/main' into feat/playwright 2023-03-10 11:28:13 +01:00
Johan Eliasson
ae19105302 cleanup 2023-03-02 21:32:34 +01:00
Johan Eliasson
730a482598 optimization 2023-03-02 21:25:43 +01:00
Johan Eliasson
253dd235ca added changeset 2023-03-01 09:43:00 +01:00
Johan Eliasson
991e8f2d15 removed unused code 2023-02-28 19:57:51 +01:00
Johan Eliasson
e500e87022 review fixes 2023-02-28 19:15:25 +01:00
Johan Eliasson
c684d0307b Update dashboard/src/utils/CONSTANTS.ts
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-02-28 16:59:35 +01:00
Johan Eliasson
2d657b9c29 styled 2023-02-28 13:42:22 +01:00
Johan Eliasson
f46d96bafc query fix 2023-02-27 17:33:26 +01:00
Johan Eliasson
8261743bd3 show warning if max free projects has been created by the user already 2023-02-27 10:44:52 +01:00
Johan Eliasson
34cf1d79a0 readability 2023-02-26 15:01:07 +01:00
Johan Eliasson
9d4542b3db revert back 2023-02-26 14:51:14 +01:00
Johan Eliasson
bb5dbdf5a3 small cleanup 2023-02-26 14:49:44 +01:00
Johan Eliasson
2801b03bf4 removed unused code 2023-02-26 09:57:46 +01:00
Johan Eliasson
8298d458d5 cleanup 2023-02-26 09:56:58 +01:00
Johan Eliasson
6e9b941b89 handle slug server side 2023-02-26 09:54:00 +01:00
Johan Eliasson
5dd25941e5 update 2023-02-26 09:25:40 +01:00
Szilárd Dóró
cfcb97b8ee chore(actions): update workflow 2023-02-22 11:04:59 +01:00
Szilárd Dóró
a1ffad77eb chore(dashboard): move sign in to global setup
- add test skeletons for the Overview
2023-02-20 17:19:46 +01:00
Szilárd Dóró
de4d59da99 feat(dashboard): add Playwright to the dashboard 2023-02-20 15:44:45 +01:00
Dipak Parmar
ce4b655c55 fix: correct typos 2022-11-22 19:47:21 -08:00
Dipak Parmar
dc57d31ec9 fix: correct extra space in azureadprovidersettings dir 2022-11-22 19:45:38 -08:00
Dipak Parmar
ea29fd6b73 feat(dashboard-settings): add azuread provider to settings 2022-11-21 20:30:53 -08:00
Dipak Parmar
d8e4073957 feat(dashboard-settings): add azuread provider settings component 2022-11-21 20:29:34 -08:00
Dipak Parmar
3f399a54a3 feat(graphql): add azuread to signinmethods query 2022-11-21 20:28:50 -08:00
222 changed files with 6239 additions and 2950 deletions

View File

@@ -23,9 +23,7 @@ runs:
- uses: actions/cache@v3 - uses: actions/cache@v3
id: pnpm-cache id: pnpm-cache
with: with:
path: | path: ${{ steps.pnpm-cache-dir.outputs.dir }}
${{ steps.pnpm-cache-dir.outputs.dir }}
~/.cache/Cypress
key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }} key: ${{ runner.os }}-node-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: ${{ runner.os }}-node- restore-keys: ${{ runner.os }}-node-
- name: Use Node.js 16 - name: Use Node.js 16

View File

@@ -169,7 +169,7 @@ jobs:
EXPRESSION='s/"'$IMAGE':[0-9]\+\.[0-9]\+\.[0-9]\+"/"'$IMAGE':'$VERSION'"/g' EXPRESSION='s/"'$IMAGE':[0-9]\+\.[0-9]\+\.[0-9]\+"/"'$IMAGE':'$VERSION'"/g'
find ./ -type f -exec sed -i -e $EXPRESSION {} \; find ./ -type f -exec sed -i -e $EXPRESSION {} \;
- name: Create Pull Request - name: Create Pull Request
uses: peter-evans/create-pull-request@v4 uses: peter-evans/create-pull-request@v5
with: with:
token: ${{ secrets.GH_PAT }} token: ${{ secrets.GH_PAT }}
commit-message: 'chore: bump nhost/dashboard to ${{ needs.version.outputs.dashboardVersion }}' commit-message: 'chore: bump nhost/dashboard to ${{ needs.version.outputs.dashboardVersion }}'

View File

@@ -19,6 +19,12 @@ env:
NEXT_PUBLIC_ENV: dev NEXT_PUBLIC_ENV: dev
NEXT_TELEMETRY_DISABLED: 1 NEXT_TELEMETRY_DISABLED: 1
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337 NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
jobs: jobs:
build: build:
@@ -60,47 +66,6 @@ jobs:
outputs: outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }} matrix: ${{ steps.set-matrix.outputs.matrix }}
e2e:
name: 'e2e (${{ matrix.package.path }})'
needs: build
if: ${{ needs.build.outputs.matrix != '[]' && needs.build.outputs.matrix != '' }}
strategy:
# * Don't cancel other matrices when one fails
fail-fast: false
matrix:
package: ${{ fromJson(needs.build.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
# * Install Nhost CLI if a `nhost/config.yaml` file is found
- name: Install Nhost CLI
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != ''
uses: ./.github/actions/nhost-cli
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
- name: Run e2e test
run: pnpm --filter="${{ matrix.package.name }}" run e2e
- id: file-name
if: ${{ failure() }}
name: Tranform package name into a valid file name
run: |
PACKAGE_FILE_NAME=$(echo "${{ matrix.package.name }}" | sed 's/@//g; s/\//-/g')
echo "fileName=$PACKAGE_FILE_NAME" >> $GITHUB_OUTPUT
# * Run this step only if the previous step failed, and some Cypress screenshots/videos exist
- name: Upload Cypress videos and screenshots
if: ${{ failure() && hashFiles(format('{0}/cypress/screenshots/**', matrix.package.path), format('{0}/cypress/videos/**', matrix.package.path)) != ''}}
uses: actions/upload-artifact@v3
with:
name: cypress-${{ steps.file-name.outputs.fileName }}
path: |
${{format('{0}/cypress/screenshots/**', matrix.package.path)}}
${{format('{0}/cypress/videos/**', matrix.package.path)}}
unit: unit:
name: Unit tests name: Unit tests
needs: build needs: build
@@ -141,3 +106,57 @@ jobs:
# * Run every `lint` script in the workspace . Dependencies build is cached by Turborepo # * Run every `lint` script in the workspace . Dependencies build is cached by Turborepo
- name: Lint - name: Lint
run: pnpm run lint:all run: pnpm run lint:all
e2e:
name: 'E2E (Package: ${{ matrix.package.path }})'
needs: build
if: ${{ needs.build.outputs.matrix != '[]' && needs.build.outputs.matrix != '' }}
strategy:
# * Don't cancel other matrices when one fails
fail-fast: false
matrix:
package: ${{ fromJson(needs.build.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
- name: Install Node and dependencies
uses: ./.github/actions/install-dependencies
with:
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
TURBO_TEAM: ${{ env.TURBO_TEAM }}
# * Install Nhost CLI if a `nhost/config.yaml` file is found
- name: Install Nhost CLI
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != ''
uses: ./.github/actions/nhost-cli
- name: Fetch Dashboard Preview URL
id: fetch-dashboard-preview-url
uses: zentered/vercel-preview-url@v1.1.9
if: github.ref_name != 'main'
env:
VERCEL_TOKEN: ${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
GITHUB_REF: ${{ github.ref_name }}
GITHUB_REPOSITORY: ${{ github.repository }}
with:
vercel_team_id: ${{ secrets.DASHBOARD_VERCEL_TEAM_ID }}
vercel_project_id: ${{ secrets.DASHBOARD_STAGING_VERCEL_PROJECT_ID }}
vercel_state: BUILDING,READY,INITIALIZING
- name: Set Dashboard Preview URL
if: steps.fetch-dashboard-preview-url.outputs.preview_url != ''
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
- name: Run e2e tests
run: pnpm --filter="${{ matrix.package.name }}" run e2e
- id: file-name
if: ${{ failure() }}
name: Transform package name into a valid file name
run: |
PACKAGE_FILE_NAME=$(echo "${{ matrix.package.name }}" | sed 's/@//g; s/\//-/g')
echo "fileName=$PACKAGE_FILE_NAME" >> $GITHUB_OUTPUT
# * Run this step only if the previous step failed, and Playwright generated a report
- name: Upload Playwright Report
if: ${{ failure() && hashFiles(format('{0}/playwright-report/**', matrix.package.path)) != ''}}
uses: actions/upload-artifact@v3
with:
name: playwright-${{ steps.file-name.outputs.fileName }}
path: ${{format('{0}/playwright-report/**', matrix.package.path)}}

View File

@@ -9,6 +9,7 @@ env:
NEXT_PUBLIC_ENV: dev NEXT_PUBLIC_ENV: dev
NEXT_TELEMETRY_DISABLED: 1 NEXT_TELEMETRY_DISABLED: 1
NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337 NEXT_PUBLIC_NHOST_BACKEND_URL: http://localhost:1337
jobs: jobs:
build: build:
name: Build name: Build

View File

@@ -23,8 +23,8 @@ module.exports = {
'e2e/**/*.ts', 'e2e/**/*.ts',
'e2e/**/*.d.ts' 'e2e/**/*.d.ts'
], ],
plugins: ['@typescript-eslint', 'cypress'], plugins: ['@typescript-eslint'],
extends: ['plugin:cypress/recommended'], extends: [],
parserOptions: { parserOptions: {
ecmaVersion: 2020, ecmaVersion: 2020,
sourceType: 'module' sourceType: 'module'

View File

@@ -8,7 +8,11 @@ module.exports = {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
project: './tsconfig.json', project: './tsconfig.json',
}, },
ignorePatterns: ['**/.eslintrc.js', '**/prettier.config.js'], ignorePatterns: [
'**/.eslintrc.js',
'**/prettier.config.js',
'**/next.config.js',
],
rules: { rules: {
'react/react-in-jsx-scope': 'off', 'react/react-in-jsx-scope': 'off',
'react/jsx-props-no-spreading': 'off', 'react/jsx-props-no-spreading': 'off',
@@ -21,6 +25,7 @@ module.exports = {
'error', 'error',
{ allowArrowFunctions: true, allowFunctions: true }, { allowArrowFunctions: true, allowFunctions: true },
], ],
'import/prefer-default-export': 'off',
'import/no-extraneous-dependencies': ['error', { devDependencies: true }], 'import/no-extraneous-dependencies': ['error', { devDependencies: true }],
curly: ['error', 'all'], curly: ['error', 'all'],
'no-restricted-exports': 'off', 'no-restricted-exports': 'off',

View File

@@ -49,4 +49,9 @@ tailwind.json
.idea .idea
# Do not ignore Logs page # Do not ignore Logs page
!src/**/logs* !src/**/logs*
/test-results/
/playwright-report/
/playwright/.cache/
storageState.json
e2e/.auth/*

View File

@@ -51,7 +51,7 @@ export const decorators = [
(Story) => ( (Story) => (
<NhostApolloProvider <NhostApolloProvider
fetchPolicy="cache-first" fetchPolicy="cache-first"
graphqlUrl="http://localhost:1337/v1/graphql" graphqlUrl="https://local.graphql.nhost.run/v1"
> >
<Story /> <Story />
</NhostApolloProvider> </NhostApolloProvider>

View File

@@ -1,5 +1,104 @@
# @nhost/dashboard # @nhost/dashboard
## 0.14.5
### Patch Changes
- ba0d57ee: fix(i18n): revert i18n library
- 3328ed05: feat(projects): improve overview when there is an error
## 0.14.4
### Patch Changes
- 5e0920ba: chore(deps): bump `next-seo` to v6
- 706c9dc3: chore(deps): bump `@types/react` to 18.0.33
- 99f8f6b3: feat(metrics): show metrics on the overview
## 0.14.3
### Patch Changes
- @nhost/react-apollo@5.0.16
## 0.14.2
### Patch Changes
- 3cb67300: fix(logs): don't break UI when clearing time picker
- 7453bf3b: feat(projects): show project creator info
- c166dad0: chore(tests): improve auth page tests
- 6a290bb2: chore(deps): bump `@types/react` to 18.0.32
## 0.14.1
### Patch Changes
- @nhost/react-apollo@5.0.15
- @nhost/nextjs@1.13.19
## 0.14.0
### Minor Changes
- 6e1f03ea: feat(dashboard): add support for the Azure AD provider
### Patch Changes
- 1bd2c373: chore(deps): bump `turbo` to 1.8.6
- d329b621: chore(deps): bump `@types/react` to 18.0.30
- cb248f0d: fix(tests): avoid name collision in database tests
- 867c8076: chore(deps): bump `@types/react` to 18.0.29
## 0.13.10
### Patch Changes
- e93b06ab: fix(dashboard): remove left margin from workspace list on mobile
- 1c4806bf: chore(deps): bump `sharp` to 0.32.0
- @nhost/react-apollo@5.0.14
- @nhost/nextjs@1.13.18
## 0.13.9
### Patch Changes
- 912ed76c: chore(dashboard): bump `@apollo/client` to 3.7.10
- Updated dependencies [912ed76c]
- @nhost/react-apollo@5.0.13
## 0.13.8
### Patch Changes
- 7c127372: chore(dashboard): bump `react-error-boundary` to v4
## 0.13.7
### Patch Changes
- 9130ab12: chore(dashboard): bump `yup` to v1 and `@hookform/resolvers` to v3
## 0.13.6
### Patch Changes
- 253dd235: using new mutation to create projects + refactor Create Project page.
## 0.13.5
### Patch Changes
- @nhost/react-apollo@5.0.12
- @nhost/nextjs@1.13.17
## 0.13.4
### Patch Changes
- b48bc034: fix(dashboard): disable new users
- 798e591b: fix(dashboard): show correct date in data grid
## 0.13.3 ## 0.13.3
### Patch Changes ### Patch Changes

View File

@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
RUN apk update RUN apk update
WORKDIR /app WORKDIR /app
RUN yarn global add turbo@1.8.3 RUN yarn global add turbo@1.8.6
COPY . . COPY . .
RUN turbo prune --scope="@nhost/dashboard" --docker RUN turbo prune --scope="@nhost/dashboard" --docker

View File

@@ -64,16 +64,15 @@ pnpm storybook
### Environment Variables for Local Development and Self-Hosting ### Environment Variables for Local Development and Self-Hosting
| Name | Description | | Name | Description |
| ---- | ----------- | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `NEXT_PUBLIC_NHOST_AUTH_URL` | The URL of the Auth service. When working locally, point it to the Auth service started by the CLI. When self-hosting, point it to the self-hosted Auth service. |
| `NEXT_PUBLIC_NHOST_AUTH_URL` | The URL of the Auth service. When working locally, point it to the Auth service started by the CLI. When self-hosting, point it to the self-hosted Auth service. | | `NEXT_PUBLIC_NHOST_FUNCTIONS_URL` | The URL of the Functions service. When working locally, point it to the Functions service started by the CLI. When self-hosting, point it to the self-hosted Functions service. |
| `NEXT_PUBLIC_NHOST_FUNCTIONS_URL` | The URL of the Functions service. When working locally, point it to the Functions service started by the CLI. When self-hosting, point it to the self-hosted Functions service. | | `NEXT_PUBLIC_NHOST_GRAPHQL_URL` | The URL of the GraphQL service. When working locally, point it to the GraphQL service started by the CLI. When self-hosting, point it to the self-hosted GraphQL service. |
| `NEXT_PUBLIC_NHOST_GRAPHQL_URL` | The URL of the GraphQL service. When working locally, point it to the GraphQL service started by the CLI. When self-hosting, point it to the self-hosted GraphQL service. | | `NEXT_PUBLIC_NHOST_STORAGE_URL` | The URL of the Storage service. When working locally, point it to the Storage service started by the CLI. When self-hosting, point it to the self-hosted Storage service. |
| `NEXT_PUBLIC_NHOST_STORAGE_URL` | The URL of the Storage service. When working locally, point it to the Storage service started by the CLI. When self-hosting, point it to the self-hosted Storage service. | | `NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL` | The URL of the Hasura Console. When working locally, point it to the Hasura Console started by the CLI. When self-hosting, point it to the self-hosted Hasura Console. |
| `NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL` | The URL of the Hasura Console. When working locally, point it to the Hasura Console started by the CLI. When self-hosting, point it to the self-hosted Hasura Console. | | `NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL` | The URL of Hasura's Migrations service. When working locally, point it to the Migrations service started by the CLI. |
| `NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL` | The URL of Hasura's Migrations service. When working locally, point it to the Migrations service started by the CLI. | | `NEXT_PUBLIC_NHOST_HASURA_API_URL` | The URL of Hasura's Schema and Metadata API. When working locally, point it to the Schema and Metadata API started by the CLI. When self-hosting, point it to the self-hosted Schema and Metadata API. |
| `NEXT_PUBLIC_NHOST_HASURA_API_URL` | The URL of Hasura's Schema and Metadata API. When working locally, point it to the Schema and Metadata API started by the CLI. When self-hosting, point it to the self-hosted Schema and Metadata API. |
### Other Environment Variables ### Other Environment Variables
@@ -111,3 +110,22 @@ pnpm storybook
| `@typescript-eslint/consistent-type-imports` | Enforces `import type { Type } from 'module'` syntax. It prevents false positive circular dependency errors. | | `@typescript-eslint/consistent-type-imports` | Enforces `import type { Type } from 'module'` syntax. It prevents false positive circular dependency errors. |
| `@typescript-eslint/naming-convention` | Enforces a consistent naming convention. | | `@typescript-eslint/naming-convention` | Enforces a consistent naming convention. |
| `no-restricted-imports` | Enforces absolute imports and consistent import paths for components from `src/components/ui` folder. | | `no-restricted-imports` | Enforces absolute imports and consistent import paths for components from `src/components/ui` folder. |
### End-to-End Tests
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
```bash
pnpm e2e
```
Most of the tests require access to the Nhost test user. To run these tests, you need to set the following environment variables in `.env.test`:
```
NHOST_TEST_DASHBOARD_URL=<test_dashboard_url>
NHOST_TEST_USER_EMAIL=<test_user_email>
NHOST_TEST_USER_PASSWORD=<test_user_password>
NHOST_TEST_WORKSPACE_NAME=<test_workspace_name>
NHOST_TEST_PROJECT_NAME=<test_project_name>
NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
```

View File

@@ -0,0 +1,50 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import test, { expect } from '@playwright/test';
test('should be able to ban and unban a user', async ({ page }) => {
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
await page
.getByRole('navigation', { name: /main navigation/i })
.getByRole('link', { name: /auth/i })
.click();
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
const email = generateTestEmail();
const password = faker.internet.password();
await createUser({ page, email, password });
await page
.getByRole('button', { name: `View ${email}`, exact: true })
.click();
await page.getByRole('button', { name: /actions/i }).click();
await page.getByRole('menuitem', { name: /ban user/i }).click();
await expect(
page.getByText(/user has been banned successfully./i),
).toBeVisible();
await expect(page.locator('form').getByText(/^banned$/i)).toBeVisible();
await page.getByRole('button', { name: /actions/i }).click();
await page.getByRole('menuitem', { name: /unban user/i }).click();
await expect(
page.getByText(/user has been unbanned successfully./i),
).toBeVisible();
await expect(page.locator('form').getByText(/^banned$/i)).not.toBeVisible();
});

View File

@@ -0,0 +1,65 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
await page
.getByRole('navigation', { name: /main navigation/i })
.getByRole('link', { name: /auth/i })
.click();
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
});
test.afterAll(async () => {
await page.close();
});
test('should create a user', async () => {
const email = generateTestEmail();
const password = faker.internet.password();
await createUser({ page, email, password });
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).toBeVisible();
});
test('should not be able to create a user with an existing email', async () => {
const email = generateTestEmail();
const password = faker.internet.password();
await createUser({ page, email, password });
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).toBeVisible();
await createUser({ page, email, password });
await expect(
page.getByRole('dialog').getByText(/email already in use/i),
).toBeVisible();
});

View File

@@ -0,0 +1,96 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
await page
.getByRole('navigation', { name: /main navigation/i })
.getByRole('link', { name: /auth/i })
.click();
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
});
test.afterAll(async () => {
await page.close();
});
test('should be able to delete a user', async () => {
const email = generateTestEmail();
const password = faker.internet.password();
await createUser({ page, email, password });
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).toBeVisible();
await page
.getByRole('button', { name: `More options for ${email}`, exact: true })
.click();
await page.getByRole('menuitem', { name: /delete user/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('heading', { name: /delete user/i }),
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete the "${email}" user?`),
).toBeVisible();
await page.getByRole('button', { name: /delete/i, exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).not.toBeVisible();
});
test('should be able to delete a user from the details page', async () => {
const email = generateTestEmail();
const password = faker.internet.password();
await createUser({ page, email, password });
await page
.getByRole('button', { name: `View ${email}`, exact: true })
.click();
await page.getByRole('button', { name: /actions/i }).click();
await page.getByRole('menuitem', { name: /delete user/i }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await expect(
page.getByRole('heading', { name: /delete user/i }),
).toBeVisible();
await expect(
page.getByText(`Are you sure you want to delete the "${email}" user?`),
).toBeVisible();
await page.getByRole('button', { name: /delete/i, exact: true }).click();
await expect(
page.getByRole('button', { name: `View ${email}`, exact: true }),
).not.toBeVisible();
});

View File

@@ -0,0 +1,103 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { createUser, generateTestEmail, openProject } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
await page
.getByRole('navigation', { name: /main navigation/i })
.getByRole('link', { name: /auth/i })
.click();
await page.waitForURL(`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/users`);
});
test.afterAll(async () => {
await page.close();
});
test('should be able to verify the email of a user', async () => {
const email = generateTestEmail();
const password = faker.internet.password();
await createUser({ page, email, password });
await page
.getByRole('button', { name: `View ${email}`, exact: true })
.click();
await expect(
page.getByRole('checkbox', { name: /email verified/i }),
).not.toBeChecked();
await page.getByRole('checkbox', { name: /email verified/i }).check();
await page.getByRole('button', { name: /save/i }).click();
await expect(
page.getByText(/user settings have been updated successfully./i),
).toBeVisible();
await page
.getByRole('button', { name: `View ${email}`, exact: true })
.click();
await expect(
page.getByRole('checkbox', { name: /email verified/i }),
).toBeChecked();
});
test('should be able to verify the phone number of a user', async () => {
const email = generateTestEmail();
const password = faker.internet.password();
const phoneNumber = faker.phone.number();
await createUser({ page, email, password });
await page
.getByRole('button', { name: `View ${email}`, exact: true })
.click();
await expect(
page.getByRole('checkbox', { name: /phone number verified/i }),
).toBeDisabled();
await page.getByRole('textbox', { name: /phone number/i }).fill(phoneNumber);
await page.getByRole('checkbox', { name: /phone number verified/i }).check();
await page.getByRole('button', { name: /save/i }).click();
await expect(
page.getByText(/user settings have been updated successfully./i),
).toBeVisible();
await page
.getByRole('button', { name: `View ${email}`, exact: true })
.click();
await expect(
page.getByRole('textbox', { name: /phone number/i }),
).toHaveValue(phoneNumber);
await expect(
page.getByRole('checkbox', { name: /phone number verified/i }),
).toBeChecked();
});

View File

@@ -0,0 +1,280 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { openProject, prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
await page
.getByRole('navigation', { name: /main navigation/i })
.getByRole('link', { name: /database/i })
.click();
});
test.afterAll(async () => {
await page.close();
});
test('should create a simple table', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const tableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text' },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
);
await expect(
page.getByRole('link', { name: tableName, exact: true }),
).toBeVisible();
});
test('should create a table with unique constraints', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const tableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text', unique: true },
{ name: 'isbn', type: 'text', unique: true },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
);
await expect(
page.getByRole('link', { name: tableName, exact: true }),
).toBeVisible();
});
test('should create a table with nullable columns', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const tableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text', nullable: true },
{ name: 'description', type: 'text', nullable: true },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
);
await expect(
page.getByRole('link', { name: tableName, exact: true }),
).toBeVisible();
});
test('should create a table with an identity column', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const tableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'int4' },
{ name: 'title', type: 'text', nullable: true },
{ name: 'description', type: 'text', nullable: true },
],
});
await page.getByRole('button', { name: /identity/i }).click();
await page.getByRole('option', { name: /id/i }).click();
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
);
await expect(
page.getByRole('link', { name: tableName, exact: true }),
).toBeVisible();
});
test('should create table with foreign key constraint', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const firstTableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: firstTableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'name', type: 'text' },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
);
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const secondTableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: secondTableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text' },
{ name: 'author_id', type: 'uuid' },
],
});
await page.getByRole('button', { name: /add foreign key/i }).click();
// select column in current table
await page
.getByRole('button', { name: /column/i })
.first()
.click();
await page.getByRole('option', { name: /author_id/i }).click();
// select reference schema
await page.getByRole('button', { name: /schema/i }).click();
await page.getByRole('option', { name: /public/i }).click();
// select reference table
await page.getByRole('button', { name: /table/i }).click();
await page.getByRole('option', { name: firstTableName, exact: true }).click();
// select reference column
await page
.getByRole('button', { name: /column/i })
.nth(1)
.click();
await page.getByRole('option', { name: /id/i }).click();
await page.getByRole('button', { name: /add/i }).click();
await expect(
page.getByText(`public.${firstTableName}.id`, { exact: true }),
).toBeVisible();
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
);
await expect(
page.getByRole('link', { name: secondTableName, exact: true }),
).toBeVisible();
});
test('should not be able to create a table with a name that already exists', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const tableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'name', type: 'text' },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
);
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text' },
{ name: 'author_id', type: 'uuid' },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await expect(
page.getByText(/error: a table with this name already exists/i),
).toBeVisible();
});

View File

@@ -0,0 +1,165 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { deleteTable, openProject, prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
await page
.getByRole('navigation', { name: /main navigation/i })
.getByRole('link', { name: /database/i })
.click();
});
test.afterAll(async () => {
await page.close();
});
test('should delete a table', async () => {
const tableName = snakeCase(faker.lorem.words(3));
await page.getByRole('button', { name: /new table/i }).click();
await prepareTable({
page,
name: tableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text' },
],
});
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${tableName}`,
);
await deleteTable({
page,
name: tableName,
});
// navigate to next URL
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/**`,
);
await expect(
page.getByRole('link', { name: tableName, exact: true }),
).not.toBeVisible();
});
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const firstTableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: firstTableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'name', type: 'text' },
],
});
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${firstTableName}`,
);
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
const secondTableName = snakeCase(faker.lorem.words(3));
await prepareTable({
page,
name: secondTableName,
primaryKey: 'id',
columns: [
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
{ name: 'title', type: 'text' },
{ name: 'author_id', type: 'uuid' },
],
});
await page.getByRole('button', { name: /add foreign key/i }).click();
// select column in current table
await page
.getByRole('button', { name: /column/i })
.first()
.click();
await page.getByRole('option', { name: /author_id/i }).click();
// select reference schema
await page.getByRole('button', { name: /schema/i }).click();
await page.getByRole('option', { name: /public/i }).click();
// select reference table
await page.getByRole('button', { name: /table/i }).click();
await page.getByRole('option', { name: firstTableName, exact: true }).click();
// select reference column
await page
.getByRole('button', { name: /column/i })
.nth(1)
.click();
await page.getByRole('option', { name: /id/i }).click();
await page.getByRole('button', { name: /add/i }).click();
await expect(
page.getByText(`public.${firstTableName}.id`, { exact: true }),
).toBeVisible();
// create table
await page.getByRole('button', { name: /create/i }).click();
await page.waitForURL(
`/${TEST_WORKSPACE_SLUG}/${TEST_PROJECT_SLUG}/database/browser/default/public/${secondTableName}`,
);
await expect(
page.getByRole('link', { name: secondTableName, exact: true }),
).toBeVisible();
// try to delete the first table that is referenced by the second table
await deleteTable({
page,
name: firstTableName,
});
await expect(
page.getByText(
/constraint [a-zA-Z_]+ on table [a-zA-Z_]+ depends on table [a-zA-Z_]+/i,
),
).toBeVisible();
});

48
dashboard/e2e/env.ts Normal file
View File

@@ -0,0 +1,48 @@
import slugify from 'slugify';
/**
* URL of the dashboard to test against.
*/
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL;
/**
* Name of the workspace to test against.
*/
export const TEST_WORKSPACE_NAME = process.env.NHOST_TEST_WORKSPACE_NAME;
/**
* Slugified name of the workspace to test against.
*/
export const TEST_WORKSPACE_SLUG = slugify(TEST_WORKSPACE_NAME, {
lower: true,
strict: true,
});
/**
* Name of the project to test against.
*/
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME;
/**
* Slugified name of the project to test against.
*/
export const TEST_PROJECT_SLUG = slugify(TEST_PROJECT_NAME, {
lower: true,
strict: true,
});
/**
* Hasura admin secret of the test project to use.
*/
export const TEST_PROJECT_ADMIN_SECRET =
process.env.NHOST_TEST_PROJECT_ADMIN_SECRET;
/**
* Email of the test account to use.
*/
export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL;
/**
* Password of the test account to use.
*/
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD;

View File

@@ -0,0 +1,119 @@
import {
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_NAME,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { openProject } from '@/e2e/utils';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
});
test.afterAll(async () => {
await page.close();
});
test('should show a sidebar with menu items', async () => {
const navLocator = page.getByRole('navigation', { name: /main navigation/i });
await expect(navLocator).toBeVisible();
await expect(navLocator.getByRole('list').getByRole('listitem')).toHaveCount(
10,
);
await expect(
navLocator.getByRole('link', { name: /overview/i }),
).toBeVisible();
await expect(
navLocator.getByRole('link', { name: /database/i }),
).toBeVisible();
await expect(
navLocator.getByRole('link', { name: /graphql/i }),
).toBeVisible();
await expect(navLocator.getByRole('link', { name: /hasura/i })).toBeVisible();
await expect(navLocator.getByRole('link', { name: /auth/i })).toBeVisible();
await expect(
navLocator.getByRole('link', { name: /storage/i }),
).toBeVisible();
await expect(
navLocator.getByRole('link', { name: /deployments/i }),
).toBeVisible();
await expect(
navLocator.getByRole('link', { name: /backups/i }),
).toBeVisible();
await expect(navLocator.getByRole('link', { name: /logs/i })).toBeVisible();
await expect(
navLocator.getByRole('link', { name: /settings/i }),
).toBeVisible();
});
test('should show a header with a logo, the workspace name, and the project name', async () => {
await expect(
page.getByRole('banner').getByRole('link', { name: TEST_WORKSPACE_NAME }),
).toBeVisible();
await expect(
page.getByRole('banner').getByRole('link', { name: TEST_PROJECT_NAME }),
).toBeVisible();
});
test("should show the project's name, the Upgrade button and the Settings button", async () => {
await expect(
page.getByRole('heading', { name: TEST_PROJECT_NAME }),
).toBeVisible();
await expect(page.getByText(/starter/i)).toBeVisible();
await expect(page.getByRole('button', { name: /upgrade/i })).toBeVisible();
await expect(
page.getByRole('main').getByRole('link', { name: /settings/i }),
).toBeVisible();
});
test("should show the project's region and subdomain", async () => {
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
/frankfurt \(eu-central-1\)/i,
);
await expect(
page.locator('p:has-text("Subdomain") + div p').nth(0),
).toHaveText(/[a-z]{20}/i);
});
test('should not have a GitHub repository connected', async () => {
await expect(
page.getByRole('button', { name: /connect to github/i }),
).toBeVisible();
});
test('should show metrics', async () => {
await expect(page.getByText(/cpu usage seconds\d+/i)).toBeVisible();
await expect(page.getByText(/total requests\d+/i)).toBeVisible();
await expect(page.getByText(/function invocations\d+/i)).toBeVisible();
await expect(
page.getByText(/egress volume\d+(\.\d+)? [a-zA-Z]+/i),
).toBeVisible();
await expect(page.getByText(/logs\d+(\.\d+)? [a-zA-Z]+/i)).toBeVisible();
});
test('should show proper limits for the free project', async () => {
await expect(
page.getByText(/database\d+(\.\d+)? [a-zA-Z]+ of \d+(\.\d+)? [a-zA-Z]+/i),
).toBeVisible();
await expect(
page.getByText(/storage\d+(\.\d+)? [a-zA-Z]+ of \d+(\.\d+)? [a-zA-Z]+/i),
).toBeVisible();
await expect(page.getByText(/users[0-9]+ of [0-9]+/i)).toBeVisible();
await expect(page.getByText(/functions[0-9]+ of [0-9]+/i)).toBeVisible();
});

View File

@@ -0,0 +1,20 @@
import {
TEST_DASHBOARD_URL,
TEST_USER_EMAIL,
TEST_USER_PASSWORD,
} from '@/e2e/env';
import { test as setup } from '@playwright/test';
setup('authenticate user', async ({ page }) => {
await page.goto('/');
await page.waitForURL('/signin');
await page.getByRole('link', { name: /continue with email/i }).click();
await page.waitForURL('/signin/email');
await page.getByLabel('Email').fill(TEST_USER_EMAIL);
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
await page.getByRole('button', { name: /sign in/i }).click();
await page.waitForURL(TEST_DASHBOARD_URL);
await page.context().storageState({ path: 'e2e/.auth/user.json' });
});

189
dashboard/e2e/utils.ts Normal file
View File

@@ -0,0 +1,189 @@
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
/**
* Open a project by navigating to the project's overview page.
*
* @param page - The Playwright page object.
* @param workspaceSlug - The slug of the workspace that contains the project.
* @param projectSlug - The slug of the project to open.
* @param projectName - The name of the project to open.
* @returns A promise that resolves when the project is opened.
*/
export async function openProject({
page,
projectName,
workspaceSlug,
projectSlug,
}: {
page: Page;
workspaceSlug: string;
projectSlug: string;
projectName: string;
}) {
await page.getByRole('link', { name: projectName }).click();
await page.waitForURL(`/${workspaceSlug}/${projectSlug}`);
}
/**
* Prepares a table by filling out the form.
*
* @param page - The Playwright page object.
* @param name - The name of the table to create.
* @param columns - The columns to create in the table.
* @returns A promise that resolves when the table is prepared.
*/
export async function prepareTable({
page,
name: tableName,
primaryKey,
columns,
}: {
page: Page;
name: string;
primaryKey: string;
columns: Array<{
name: string;
type: string;
nullable?: boolean;
unique?: boolean;
defaultValue?: string;
}>;
}) {
if (!columns.some(({ name }) => name === primaryKey)) {
throw new Error('Primary key must be one of the columns.');
}
await page.getByRole('textbox', { name: /name/i }).first().fill(tableName);
await Promise.all(
columns.map(
async (
{ name: columnName, type, nullable, unique, defaultValue },
index,
) => {
// set name
await page.getByPlaceholder(/name/i).nth(index).fill(columnName);
// set type
await page
.getByRole('table')
.getByRole('combobox', { name: /type/i })
.nth(index)
.type(type);
await page
.getByRole('table')
.getByRole('option', { name: type })
.first()
.click();
// optionally set default value
if (defaultValue) {
await page
.getByRole('table')
.getByRole('combobox', { name: /default value/i })
.nth(index)
.type(defaultValue);
await page
.getByRole('table')
.getByRole('option', { name: defaultValue })
.first()
.click();
}
// optionally check unique
if (unique) {
await page
.getByRole('checkbox', { name: /unique/i })
.nth(index)
.check();
}
// optionally check nullable
if (nullable) {
await page
.getByRole('checkbox', { name: /nullable/i })
.nth(index)
.check();
}
// add new column if not last
if (index < columns.length - 1) {
await page.getByRole('button', { name: /add column/i }).click();
}
},
),
);
// select the first column as primary key
await page.getByRole('button', { name: /primary key/i }).click();
await page.getByRole('option', { name: primaryKey, exact: true }).click();
}
/**
* Deletes a table with the given name.
*
* @param page - The Playwright page object.
* @param name - The name of the table to delete.
* @returns A promise that resolves when the table is deleted.
*/
export async function deleteTable({
page,
name,
}: {
page: Page;
name: string;
}) {
const tableLink = page.getByRole('link', {
name,
exact: true,
});
await tableLink.hover();
await page
.getByRole('listitem')
.filter({ hasText: name })
.getByRole('button')
.click();
await page.getByRole('menuitem', { name: /delete table/i }).click();
await page.getByRole('button', { name: /delete/i }).click();
}
/**
* Creates a new user.
*
* @param page - The Playwright page object.
* @param email - The email of the user to create.
* @param password - The password of the user to create.
* @returns A promise that resolves when the user is created.
*/
export async function createUser({
page,
email,
password,
}: {
page: Page;
email: string;
password: string;
}) {
await page
.getByRole('button', { name: /create user/i })
.first()
.click();
await page.getByRole('textbox', { name: /email/i }).fill(email);
await page.getByRole('textbox', { name: /password/i }).fill(password);
await page.getByRole('button', { name: /create/i, exact: true }).click();
}
/**
* Generates a test email address with the given prefix (if provided).
*
* @param prefix - The prefix to use for the email address. (Default: `Nhost_Test_`)
*/
export function generateTestEmail(prefix: string = 'Nhost_Test_') {
const email = faker.internet.email();
return [prefix, email].join('');
}

View File

@@ -0,0 +1,66 @@
import {
TEST_DASHBOARD_URL,
TEST_PROJECT_ADMIN_SECRET,
TEST_PROJECT_NAME,
TEST_PROJECT_SLUG,
TEST_WORKSPACE_SLUG,
} from '@/e2e/env';
import { openProject } from '@/e2e/utils';
import { chromium } from '@playwright/test';
async function globalTeardown() {
const browser = await chromium.launch();
const context = await browser.newContext({
baseURL: TEST_DASHBOARD_URL,
storageState: 'e2e/.auth/user.json',
});
const page = await context.newPage();
await page.goto('/');
await openProject({
page,
projectName: TEST_PROJECT_NAME,
workspaceSlug: TEST_WORKSPACE_SLUG,
projectSlug: TEST_PROJECT_SLUG,
});
const pagePromise = context.waitForEvent('page');
await page.getByRole('link', { name: /hasura/i }).click();
await page.getByRole('link', { name: /open hasura/i }).click();
const hasuraPage = await pagePromise;
await hasuraPage.waitForLoadState();
const adminSecretInput = hasuraPage.getByPlaceholder(/enter admin-secret/i);
// note: a more ideal way would be to paste from clipboard, but Playwright
// doesn't support that yet
await adminSecretInput.fill(TEST_PROJECT_ADMIN_SECRET);
await adminSecretInput.press('Enter');
// note: getByRole doesn't work here
await hasuraPage.locator('a', { hasText: /data/i }).click();
await hasuraPage.getByRole('link', { name: /sql/i }).click();
await hasuraPage.getByRole('textbox').fill(`
DO $$ DECLARE
tablename text;
BEGIN
FOR tablename IN
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'
LOOP
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
END LOOP;
END $$;
`);
await hasuraPage.getByRole('button', { name: /run!/i }).click();
await hasuraPage.getByText(/sql executed!/i).waitFor();
}
export default globalTeardown;

View File

@@ -1,5 +1,5 @@
schema: schema:
- http://localhost:1337/v1/graphql: - https://local.graphql.nhost.run/v1:
headers: headers:
x-hasura-admin-secret: nhost-admin-secret x-hasura-admin-secret: nhost-admin-secret
generates: generates:

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nhost/dashboard", "name": "@nhost/dashboard",
"version": "0.13.3", "version": "0.14.5",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -14,10 +14,11 @@
"nhost:dev": "nhost dev -d", "nhost:dev": "nhost dev -d",
"format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.", "format": "prettier --write \"src/**/*.{js,ts,tsx,jsx,json,md}\" --plugin-search-dir=.",
"storybook": "start-storybook -p 6006 -s public", "storybook": "start-storybook -p 6006 -s public",
"build-storybook": "build-storybook" "build-storybook": "build-storybook",
"e2e": "npx playwright@1.31.2 install --with-deps && playwright test"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.7.3", "@apollo/client": "^3.7.10",
"@codemirror/language": "^6.3.0", "@codemirror/language": "^6.3.0",
"@emotion/cache": "^11.10.5", "@emotion/cache": "^11.10.5",
"@emotion/react": "^11.10.5", "@emotion/react": "^11.10.5",
@@ -29,7 +30,7 @@
"@graphiql/toolkit": "^0.8.2", "@graphiql/toolkit": "^0.8.2",
"@headlessui/react": "^1.6.5", "@headlessui/react": "^1.6.5",
"@heroicons/react": "^1.0.6", "@heroicons/react": "^1.0.6",
"@hookform/resolvers": "^2.9.10", "@hookform/resolvers": "^3.0.0",
"@mui/base": "^5.0.0-alpha.106", "@mui/base": "^5.0.0-alpha.106",
"@mui/material": "^5.10.14", "@mui/material": "^5.10.14",
"@mui/system": "^5.10.14", "@mui/system": "^5.10.14",
@@ -56,13 +57,12 @@
"just-kebab-case": "^4.1.1", "just-kebab-case": "^4.1.1",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"next": "^12.3.1", "next": "^12.3.1",
"next-seo": "^5.14.1", "next-seo": "^6.0.0",
"node-pg-format": "^1.3.5", "node-pg-format": "^1.3.5",
"pluralize": "^8.0.0", "pluralize": "^8.0.0",
"prettysize": "^2.0.0",
"react": "18.2.0", "react": "18.2.0",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "^3.1.4", "react-error-boundary": "^4.0.0",
"react-hook-form": "^7.42.1", "react-hook-form": "^7.42.1",
"react-hot-toast": "^2.4.0", "react-hot-toast": "^2.4.0",
"react-is": "18.2.0", "react-is": "18.2.0",
@@ -70,23 +70,25 @@
"react-merge-refs": "^1.1.0", "react-merge-refs": "^1.1.0",
"react-syntax-highlighter": "^15.4.5", "react-syntax-highlighter": "^15.4.5",
"react-table": "^7.8.0", "react-table": "^7.8.0",
"sharp": "^0.31.2", "sharp": "^0.32.0",
"slugify": "^1.6.5", "slugify": "^1.6.5",
"stripe": "^10.17.0", "stripe": "^10.17.0",
"tailwind-merge": "^1.8.0", "tailwind-merge": "^1.8.0",
"utility-types": "^3.10.0", "utility-types": "^3.10.0",
"validator": "^13.7.0", "validator": "^13.7.0",
"yup": "^0.32.11", "yup": "^1.0.2",
"yup-password": "^0.2.2" "yup-password": "^0.2.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.20.2", "@babel/core": "^7.20.2",
"@faker-js/faker": "^7.6.0",
"@graphql-codegen/cli": "^3.0.0", "@graphql-codegen/cli": "^3.0.0",
"@graphql-codegen/typescript": "^3.0.0", "@graphql-codegen/typescript": "^3.0.0",
"@graphql-codegen/typescript-graphql-request": "^4.5.1", "@graphql-codegen/typescript-graphql-request": "^4.5.1",
"@graphql-codegen/typescript-operations": "^3.0.0", "@graphql-codegen/typescript-operations": "^3.0.0",
"@graphql-codegen/typescript-react-apollo": "^3.3.1", "@graphql-codegen/typescript-react-apollo": "^3.3.1",
"@next/bundle-analyzer": "^12.3.1", "@next/bundle-analyzer": "^12.3.1",
"@playwright/test": "^1.31.2",
"@storybook/addon-actions": "^6.5.14", "@storybook/addon-actions": "^6.5.14",
"@storybook/addon-essentials": "^6.5.14", "@storybook/addon-essentials": "^6.5.14",
"@storybook/addon-interactions": "^6.5.14", "@storybook/addon-interactions": "^6.5.14",
@@ -103,7 +105,7 @@
"@types/lodash.debounce": "^4.0.7", "@types/lodash.debounce": "^4.0.7",
"@types/node": "^16.11.7", "@types/node": "^16.11.7",
"@types/pluralize": "^0.0.29", "@types/pluralize": "^0.0.29",
"@types/react": "18.0.28", "@types/react": "18.0.33",
"@types/react-dom": "18.0.11", "@types/react-dom": "18.0.11",
"@types/react-table": "^7.7.12", "@types/react-table": "^7.7.12",
"@types/testing-library__jest-dom": "^5.14.5", "@types/testing-library__jest-dom": "^5.14.5",
@@ -116,6 +118,7 @@
"babel-loader": "^8.3.0", "babel-loader": "^8.3.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"csstype": "^3.0.10", "csstype": "^3.0.10",
"dotenv": "^16.0.3",
"encoding": "^0.1.13", "encoding": "^0.1.13",
"eslint": "^8.28.0", "eslint": "^8.28.0",
"eslint-config-airbnb": "19.0.4", "eslint-config-airbnb": "19.0.4",
@@ -137,6 +140,7 @@
"prettier-plugin-tailwindcss": "^0.2.0", "prettier-plugin-tailwindcss": "^0.2.0",
"react-date-fns-hooks": "^0.9.4", "react-date-fns-hooks": "^0.9.4",
"require-from-string": "^2.0.2", "require-from-string": "^2.0.2",
"snake-case": "^3.0.4",
"storybook-addon-next-router": "^4.0.1", "storybook-addon-next-router": "^4.0.1",
"tailwindcss": "^3.1.2", "tailwindcss": "^3.1.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
@@ -161,4 +165,4 @@
"msw": { "msw": {
"workerDirectory": "public" "workerDirectory": "public"
} }
} }

View File

@@ -0,0 +1,38 @@
import { defineConfig, devices } from '@playwright/test';
import dotenv from 'dotenv';
import path from 'path';
dotenv.config({ path: path.resolve(__dirname, '.env.test') });
export default defineConfig({
testDir: './e2e',
timeout: 30 * 1000,
expect: {
timeout: 5000,
},
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
globalTeardown: require.resolve('./global-teardown'),
use: {
actionTimeout: 0,
trace: 'on-first-retry',
baseURL: process.env.NHOST_TEST_DASHBOARD_URL,
},
projects: [
{
name: 'setup',
testMatch: ['**/setup/*.setup.ts'],
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json',
},
dependencies: ['setup'],
},
],
});

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -10,24 +10,23 @@ import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import { Dropdown } from '@/ui/v2/Dropdown'; import { Dropdown } from '@/ui/v2/Dropdown';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { getPreviousApplicationState } from '@/utils/getPreviousApplicationState';
import { getApplicationStatusString } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast';
import { updateOwnCache } from '@/utils/updateOwnCache';
import { import {
useDeleteApplicationMutation, useDeleteApplicationMutation,
useGetApplicationStateQuery, useGetApplicationStateQuery,
useInsertApplicationMutation, useInsertApplicationMutation,
useUpdateApplicationMutation, useUpdateApplicationMutation,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { getPreviousApplicationState } from '@/utils/getPreviousApplicationState';
import { getApplicationStatusString } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast';
import { updateOwnCache } from '@/utils/updateOwnCache';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { useUserData } from '@nhost/nextjs'; import { useUserData } from '@nhost/nextjs';
import Image from 'next/image'; import Image from 'next/image';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import ApplicationInfo from './ApplicationInfo'; import ApplicationInfo from './ApplicationInfo';
import ApplicationLive from './ApplicationLive'; import ApplicationLive from './ApplicationLive';
import ApplicationUnknown from './ApplicationUnknown';
import { RemoveApplicationModal } from './RemoveApplicationModal'; import { RemoveApplicationModal } from './RemoveApplicationModal';
import { StagingMetadata } from './StagingMetadata'; import { StagingMetadata } from './StagingMetadata';
@@ -47,9 +46,9 @@ export default function ApplicationErrored() {
variables: { appId: currentApplication.id }, variables: { appId: currentApplication.id },
}); });
const [previousState, setPreviousState] = useState<ApplicationStatus | null>( const previousState = data?.app?.appStates
null, ? getPreviousApplicationState(data.app.appStates)
); : null;
const [showRecreateModal, setShowRecreateModal] = useState(false); const [showRecreateModal, setShowRecreateModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -140,20 +139,6 @@ export default function ApplicationErrored() {
await recreateApplication(); await recreateApplication();
} }
useEffect(() => {
if (loading) {
return;
}
if (error) {
return;
}
const previousAcceptedState = getPreviousApplicationState(
data.app.appStates,
);
setPreviousState(previousAcceptedState);
}, [setPreviousState, data, loading, error]);
if (loading || previousState === null) { if (loading || previousState === null) {
return ( return (
<Container className="mx-auto mt-12 max-w-sm text-center"> <Container className="mx-auto mt-12 max-w-sm text-center">
@@ -170,19 +155,13 @@ export default function ApplicationErrored() {
return null; return null;
} }
if (previousState === ApplicationStatus.Live) { if (
return <ApplicationLive />; previousState === ApplicationStatus.Updating ||
} previousState === ApplicationStatus.Empty
) {
// For now, if the application errored and the previous state to this error is an UPDATING state, we want to show the dashboard, return (
// it's likely that most services are up and we shouldn't block all functionality. In the future, we're going to have a way to <ApplicationLive errorMessage="Error deploying the project most likely due to invalid configuration. Please review your project's configuration and logs for more information." />
// redeploy the app again, and get to a healthy state. @GC );
if (previousState === ApplicationStatus.Updating) {
return <ApplicationLive />;
}
if (previousState === ApplicationStatus.Empty) {
return <ApplicationUnknown />;
} }
return ( return (

View File

@@ -9,28 +9,39 @@ import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import { getApplicationStatusString } from '@/utils/helpers'; import { getApplicationStatusString } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast'; import getServerError from '@/utils/settings/getServerError';
import { formatDistance } from 'date-fns'; import { formatDistance } from 'date-fns';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { toast } from 'react-hot-toast';
export default function ApplicationInfo() { export default function ApplicationInfo() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const [deleteApplication, { client }] = useDeleteApplicationMutation({ const [deleteApplication] = useDeleteApplicationMutation({
refetchQueries: [GetOneUserDocument], refetchQueries: [GetOneUserDocument],
}); });
const router = useRouter(); const router = useRouter();
async function handleClickRemove() { async function handleClickRemove() {
await deleteApplication({ try {
variables: { await toast.promise(
appId: currentApplication.id, deleteApplication({
}, variables: {
}); appId: currentApplication.id,
await router.push('/'); },
await client.refetchQueries({ }),
include: ['getOneUser'], {
}); loading: 'Deleting project...',
triggerToast(`${currentApplication.name} deleted`); success: 'The project has been deleted successfully.',
error: getServerError(
'An error occurred while deleting the project. Please try again.',
),
},
);
await router.push('/');
} catch {
// Note: The toast will handle the error.
}
} }
return ( return (

View File

@@ -1,20 +1,31 @@
import MaintenanceAlert from '@/components/common/MaintenanceAlert'; import MaintenanceAlert from '@/components/common/MaintenanceAlert';
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary'; import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import Container from '@/components/layout/Container'; import Container from '@/components/layout/Container';
import { features } from '@/components/overview/features';
import { frameworks } from '@/components/overview/frameworks';
import OverviewDeployments from '@/components/overview/OverviewDeployments'; import OverviewDeployments from '@/components/overview/OverviewDeployments';
import OverviewDocumentation from '@/components/overview/OverviewDocumentation'; import OverviewDocumentation from '@/components/overview/OverviewDocumentation';
import OverviewMetrics from '@/components/overview/OverviewMetrics/OverviewMetrics';
import OverviewMigration from '@/components/overview/OverviewMigration'; import OverviewMigration from '@/components/overview/OverviewMigration';
import OverviewProjectInfo from '@/components/overview/OverviewProjectInfo'; import OverviewProjectInfo from '@/components/overview/OverviewProjectInfo';
import OverviewRepository from '@/components/overview/OverviewRepository'; import OverviewRepository from '@/components/overview/OverviewRepository';
import OverviewTopBar from '@/components/overview/OverviewTopBar'; import OverviewTopBar from '@/components/overview/OverviewTopBar';
import OverviewUsage from '@/components/overview/OverviewUsage'; import OverviewUsage from '@/components/overview/OverviewUsage';
import { features } from '@/components/overview/features';
import { frameworks } from '@/components/overview/frameworks';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Divider from '@/ui/v2/Divider'; import Divider from '@/ui/v2/Divider';
export default function ApplicationLive() { export interface ApplicationLiveProps {
/**
* Error message to display in the alert.
*/
errorMessage?: string;
}
export default function ApplicationLive({
errorMessage,
}: ApplicationLiveProps) {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const isProjectUsingRDS = currentApplication?.featureFlags.some( const isProjectUsingRDS = currentApplication?.featureFlags.some(
@@ -24,6 +35,8 @@ export default function ApplicationLive() {
if (!isPlatform) { if (!isPlatform) {
return ( return (
<Container> <Container>
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
<OverviewTopBar /> <OverviewTopBar />
<div className="grid grid-cols-1 gap-12 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-12 lg:grid-cols-3">
@@ -54,10 +67,17 @@ export default function ApplicationLive() {
return ( return (
<Container> <Container>
<MaintenanceAlert /> <MaintenanceAlert />
{errorMessage && <Alert severity="error">{errorMessage}</Alert>}
<OverviewTopBar /> <OverviewTopBar />
<div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-12 pt-3 lg:grid-cols-3">
<div className="order-2 grid grid-flow-row gap-12 lg:order-1 lg:col-span-2"> <div className="grid grid-flow-row gap-12 lg:col-span-2">
<RetryableErrorBoundary>
<OverviewMetrics />
</RetryableErrorBoundary>
<RetryableErrorBoundary> <RetryableErrorBoundary>
<OverviewDeployments /> <OverviewDeployments />
</RetryableErrorBoundary> </RetryableErrorBoundary>
@@ -66,16 +86,18 @@ export default function ApplicationLive() {
title="Pick your favorite framework and start learning" title="Pick your favorite framework and start learning"
description="Nhost integrates smoothly with all of the frameworks you already know." description="Nhost integrates smoothly with all of the frameworks you already know."
cardElements={frameworks} cardElements={frameworks}
className="hidden lg:block"
/> />
<OverviewDocumentation <OverviewDocumentation
title="Platform Documentation" title="Platform Documentation"
description="More in-depth documentation for key features." description="More in-depth documentation for key features."
cardElements={features} cardElements={features}
className="hidden lg:block"
/> />
</div> </div>
<div className="order-1 grid grid-flow-row content-start gap-8 lg:order-2 lg:col-span-1 lg:gap-12"> <div className="grid grid-flow-row content-start gap-8 lg:col-span-1 lg:gap-12">
{isProjectUsingRDS && ( {isProjectUsingRDS && (
<> <>
<OverviewMigration /> <OverviewMigration />
@@ -88,6 +110,20 @@ export default function ApplicationLive() {
<Divider /> <Divider />
<OverviewUsage /> <OverviewUsage />
</div> </div>
<OverviewDocumentation
title="Pick your favorite framework and start learning"
description="Nhost integrates smoothly with all of the frameworks you already know."
cardElements={frameworks}
className="lg:hidden"
/>
<OverviewDocumentation
title="Platform Documentation"
description="More in-depth documentation for key features."
cardElements={features}
className="lg:hidden"
/>
</div> </div>
</Container> </Container>
); );

View File

@@ -3,54 +3,81 @@ import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
import { StagingMetadata } from '@/components/applications/StagingMetadata'; import { StagingMetadata } from '@/components/applications/StagingMetadata';
import { useDialog } from '@/components/common/DialogProvider'; import { useDialog } from '@/components/common/DialogProvider';
import Container from '@/components/layout/Container'; import Container from '@/components/layout/Container';
import { useUpdateApplicationMutation } from '@/generated/graphql'; import {
GetOneUserDocument,
useGetFreeAndActiveProjectsQuery,
useUnpauseApplicationMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { ApplicationStatus } from '@/types/application';
import { Modal } from '@/ui'; import { Modal } from '@/ui';
import { Alert } from '@/ui/Alert';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { discordAnnounce } from '@/utils/discordAnnounce'; import { MAX_FREE_PROJECTS } from '@/utils/CONSTANTS';
import { triggerToast } from '@/utils/toast'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { updateOwnCache } from '@/utils/updateOwnCache'; import type { ApolloError } from '@apollo/client';
import { useUserData } from '@nhost/nextjs'; import { useUserData } from '@nhost/nextjs';
import Image from 'next/image'; import Image from 'next/image';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-hot-toast';
import { RemoveApplicationModal } from './RemoveApplicationModal'; import { RemoveApplicationModal } from './RemoveApplicationModal';
export default function ApplicationPaused() { export default function ApplicationPaused() {
const { openAlertDialog } = useDialog(); const { openAlertDialog } = useDialog();
const { currentWorkspace, currentApplication } = const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication(); useCurrentWorkspaceAndApplication();
const [changingApplicationStateLoading, setChangingApplicationStateLoading] = const { id } = useUserData();
useState(false);
const [updateApplication, { client }] = useUpdateApplicationMutation();
const { id, email } = useUserData();
const isOwner = currentWorkspace.members.some( const isOwner = currentWorkspace.members.some(
({ userId, type }) => userId === id && type === 'owner', ({ userId, type }) => userId === id && type === 'owner',
); );
const isPro = currentApplication.plan.name === 'Pro';
const [showDeletingModal, setShowDeletingModal] = useState(false); const [showDeletingModal, setShowDeletingModal] = useState(false);
const [unpauseApplication, { loading: changingApplicationStateLoading }] =
useUnpauseApplicationMutation({
refetchQueries: [GetOneUserDocument],
});
const { data, loading } = useGetFreeAndActiveProjectsQuery({
variables: { userId: id },
fetchPolicy: 'cache-and-network',
});
const numberOfFreeAndLiveProjects = data?.freeAndActiveProjects.length || 0;
const wakeUpDisabled = numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
async function handleTriggerUnpausing() { async function handleTriggerUnpausing() {
setChangingApplicationStateLoading(true);
try { try {
await updateApplication({ await toast.promise(
variables: { unpauseApplication({ variables: { appId: currentApplication.id } }),
appId: currentApplication.id, {
app: { loading: 'Starting the project...',
desiredState: ApplicationStatus.Live, success: `The project has been started successfully.`,
error: (arg: ApolloError) => {
// we need to get the internal error message from the GraphQL error
const { internal } = arg.graphQLErrors[0]?.extensions || {};
const { message } = (internal as Record<string, any>)?.error || {};
// we use the default Apollo error message if we can't find the
// internal error message
return (
message ||
arg.message ||
'An error occurred while waking up the project. Please try again.'
);
}, },
}, },
}); getToastStyleProps(),
await updateOwnCache(client);
discordAnnounce(
`App ${currentApplication.name} (${email}) set to awake.`,
); );
triggerToast(`${currentApplication.name} set to awake.`); } catch {
} catch (e) { // Note: The toast will handle the error.
triggerToast(`Error trying to awake ${currentApplication.name}`);
} }
} }
if (loading) {
return <ActivityIndicator label="Loading user data..." delay={1000} />;
}
return ( return (
<> <>
<Modal <Modal
@@ -65,7 +92,7 @@ export default function ApplicationPaused() {
/> />
</Modal> </Modal>
<Container className="mx-auto mt-20 grid max-w-sm grid-flow-row gap-2 text-center"> <Container className="mx-auto mt-20 grid max-w-lg grid-flow-row gap-4 text-center">
<div className="mx-auto flex w-centImage flex-col text-center"> <div className="mx-auto flex w-centImage flex-col text-center">
<Image <Image
src="/assets/PausedApp.svg" src="/assets/PausedApp.svg"
@@ -75,16 +102,18 @@ export default function ApplicationPaused() {
/> />
</div> </div>
<Text variant="h3" component="h1" className="mt-4"> <Box className="grid grid-flow-row gap-1">
{currentApplication.name} is sleeping <Text variant="h3" component="h1">
</Text> {currentApplication.name} is sleeping
</Text>
<Text className="mt-1"> <Text>
Projects on the free plan stop responding to API calls after 7 days of Starter projects stop responding to API calls after 7 days of
no traffic. inactivity. Upgrade to Pro to avoid autosleep.
</Text> </Text>
</Box>
{!isPro && ( <Box className="grid grid-flow-row gap-2">
<Button <Button
className="mx-auto w-full max-w-[280px]" className="mx-auto w-full max-w-[280px]"
onClick={() => { onClick={() => {
@@ -101,32 +130,41 @@ export default function ApplicationPaused() {
}); });
}} }}
> >
Upgrade to Pro to avoid autosleep Upgrade to Pro
</Button>
)}
<div className="grid grid-flow-row gap-2">
<Button
variant="borderless"
className="mx-auto w-full max-w-[280px]"
loading={changingApplicationStateLoading}
disabled={changingApplicationStateLoading}
onClick={handleTriggerUnpausing}
>
Wake Up
</Button> </Button>
{isOwner && ( <div className="grid grid-flow-row gap-2">
<Button <Button
color="error"
variant="borderless" variant="borderless"
className="mx-auto w-full max-w-[280px]" className="mx-auto w-full max-w-[280px]"
onClick={() => setShowDeletingModal(true)} loading={changingApplicationStateLoading}
disabled={changingApplicationStateLoading || wakeUpDisabled}
onClick={handleTriggerUnpausing}
> >
Delete Project Wake Up
</Button> </Button>
)}
</div> {wakeUpDisabled && (
<Alert severity="warning" className="mx-auto max-w-xs text-left">
Note: Only one free project can be active at any given time.
Please pause your active free project before unpausing{' '}
{currentApplication.name}.
</Alert>
)}
{isOwner && (
<Button
color="error"
variant="borderless"
className="mx-auto w-full max-w-[280px]"
onClick={() => setShowDeletingModal(true)}
>
Delete Project
</Button>
)}
</div>
</Box>
<StagingMetadata> <StagingMetadata>
<ApplicationInfo /> <ApplicationInfo />
</StagingMetadata> </StagingMetadata>

View File

@@ -6,7 +6,10 @@ import Divider from '@/ui/v2/Divider';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { discordAnnounce } from '@/utils/discordAnnounce'; import { discordAnnounce } from '@/utils/discordAnnounce';
import { triggerToast } from '@/utils/toast'; import { triggerToast } from '@/utils/toast';
import { useDeleteApplicationMutation } from '@/utils/__generated__/graphql'; import {
GetOneUserDocument,
useDeleteApplicationMutation,
} from '@/utils/__generated__/graphql';
import router from 'next/router'; import router from 'next/router';
import { useState } from 'react'; import { useState } from 'react';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
@@ -42,7 +45,9 @@ export function RemoveApplicationModal({
description, description,
className, className,
}: RemoveApplicationModalProps) { }: RemoveApplicationModalProps) {
const [deleteApplication, { client }] = useDeleteApplicationMutation(); const [deleteApplication] = useDeleteApplicationMutation({
refetchQueries: [GetOneUserDocument],
});
const [loadingRemove, setLoadingRemove] = useState(false); const [loadingRemove, setLoadingRemove] = useState(false);
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
@@ -73,9 +78,6 @@ export function RemoveApplicationModal({
} }
close(); close();
await router.push('/'); await router.push('/');
await client.refetchQueries({
include: ['getOneUser'],
});
triggerToast(`${currentApplication.name} deleted`); triggerToast(`${currentApplication.name} deleted`);
} }

View File

@@ -111,9 +111,8 @@ export function RenderWorkspacesWithApps({
)} )}
<StateBadge <StateBadge
status={checkStatusOfTheApplication( state={checkStatusOfTheApplication(app.appStates)}
app.appStates, desiredState={app.desiredState}
)}
title={getApplicationStatusString( title={getApplicationStatusString(
checkStatusOfTheApplication(app.appStates), checkStatusOfTheApplication(app.appStates),
)} )}

View File

@@ -6,7 +6,7 @@ import type { PropsWithChildren } from 'react';
export function StagingMetadata({ children }: PropsWithChildren<unknown>) { export function StagingMetadata({ children }: PropsWithChildren<unknown>) {
return ( return (
isDevOrStaging() && ( isDevOrStaging() && (
<div className="mt-10"> <div className="mx-auto mt-10 max-w-sm">
<Box className="mx-auto flex flex-col rounded-md border p-5 text-center"> <Box className="mx-auto flex flex-col rounded-md border p-5 text-center">
<Status status={StatusEnum.Deploying}>Internal info</Status> <Status status={StatusEnum.Deploying}>Internal info</Status>
{children} {children}

View File

@@ -76,7 +76,8 @@ function AddPaymentMethodForm({
if (createPaymentMethodError) { if (createPaymentMethodError) {
throw new Error( throw new Error(
createPaymentMethodError.message || 'Unknown error occurred.', createPaymentMethodError.message ||
'An unknown error occurred. Please try again.',
); );
} }
@@ -90,7 +91,10 @@ function AddPaymentMethodForm({
); );
if (attachPaymentMethodError) { if (attachPaymentMethodError) {
throw Error((attachPaymentMethodError as any).response.data); throw new Error(
(attachPaymentMethodError as any)?.response?.data ||
'An unknown error occurred. Please try again.',
);
} }
// update workspace with new country code in database // update workspace with new country code in database
@@ -151,7 +155,7 @@ function AddPaymentMethodForm({
}; };
return ( return (
<Box className="w-modal2 px-6 pt-6 pb-6 text-left rounded-lg"> <Box className="w-modal2 rounded-lg px-6 pt-6 pb-6 text-left">
<div className="flex flex-col"> <div className="flex flex-col">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Text className="text-center text-lg font-medium"> <Text className="text-center text-lg font-medium">
@@ -203,7 +207,7 @@ function AddPaymentMethodForm({
type BillingPaymentMethodFormProps = { type BillingPaymentMethodFormProps = {
close: () => void; close: () => void;
onPaymentMethodAdded?: () => Promise<void>; onPaymentMethodAdded?: (e?: any) => Promise<void>;
workspaceId: string; workspaceId: string;
}; };

View File

@@ -46,7 +46,7 @@ export default function DataGridDateCell<TData extends object>({
: undefined; : undefined;
const { year, month, day, hour, minute, second } = getDateComponents(date, { const { year, month, day, hour, minute, second } = getDateComponents(date, {
adjustTimezone: specificType === 'timetz' || specificType === 'timestamptz', adjustTimezone: ['date', 'timetz', 'timestamptz'].includes(specificType),
}); });
const { inputRef, focusCell, isEditing, cancelEditCell } = const { inputRef, focusCell, isEditing, cancelEditCell } =

View File

@@ -39,17 +39,17 @@ const ruleGroupSchema = Yup.object().shape({
const baseValidationSchema = Yup.object().shape({ const baseValidationSchema = Yup.object().shape({
filter: ruleGroupSchema.nullable().required('Please select a filter type.'), filter: ruleGroupSchema.nullable().required('Please select a filter type.'),
columns: Yup.array().of(Yup.string()).nullable(true), columns: Yup.array().of(Yup.string()).nullable(),
}); });
const selectValidationSchema = baseValidationSchema.shape({ const selectValidationSchema = baseValidationSchema.shape({
limit: Yup.number() limit: Yup.number()
.label('Limit') .label('Limit')
.min(0, 'Limit must not be negative.') .min(0, 'Limit must not be negative.')
.nullable(true), .nullable(),
allowAggregations: Yup.boolean().nullable(true), allowAggregations: Yup.boolean().nullable(),
queryRootFields: Yup.array().of(Yup.string()).nullable(true), queryRootFields: Yup.array().of(Yup.string()).nullable(),
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(true), subscriptionRootFields: Yup.array().of(Yup.string()).nullable(),
}); });
const columnPresetSchema = Yup.object().shape({ const columnPresetSchema = Yup.object().shape({
@@ -88,17 +88,17 @@ const columnPresetSchema = Yup.object().shape({
}); });
const insertValidationSchema = baseValidationSchema.shape({ const insertValidationSchema = baseValidationSchema.shape({
backendOnly: Yup.boolean().nullable(true), backendOnly: Yup.boolean().nullable(),
columnPresets: Yup.array().of(columnPresetSchema).nullable(true), columnPresets: Yup.array().of(columnPresetSchema).nullable(),
}); });
const updateValidationSchema = baseValidationSchema.shape({ const updateValidationSchema = baseValidationSchema.shape({
backendOnly: Yup.boolean().nullable(true), backendOnly: Yup.boolean().nullable(),
columnPresets: Yup.array().of(columnPresetSchema).nullable(true), columnPresets: Yup.array().of(columnPresetSchema).nullable(),
}); });
const deleteValidationSchema = baseValidationSchema.shape({ const deleteValidationSchema = baseValidationSchema.shape({
columnPresets: Yup.array().of(columnPresetSchema).nullable(true), columnPresets: Yup.array().of(columnPresetSchema).nullable(),
}); });
const validationSchemas: Record<DatabaseAction, Yup.ObjectSchema<any>> = { const validationSchemas: Record<DatabaseAction, Yup.ObjectSchema<any>> = {

View File

@@ -7,7 +7,7 @@ import Link from 'next/link';
export default function Sidebar() { export default function Sidebar() {
return ( return (
<div className="grid grid-flow-row gap-8 mt-2 ml-10 w-full md:grid md:w-workspaceSidebar content-start"> <div className="mt-2 grid w-full grid-flow-row content-start gap-8 md:ml-10 md:grid md:w-workspaceSidebar">
<WorkspaceSection /> <WorkspaceSection />
<Resources /> <Resources />

View File

@@ -9,6 +9,7 @@ import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWor
import { useNavigationVisible } from '@/hooks/useNavigationVisible'; import { useNavigationVisible } from '@/hooks/useNavigationVisible';
import useNotFoundRedirect from '@/hooks/useNotFoundRedirect'; import useNotFoundRedirect from '@/hooks/useNotFoundRedirect';
import { useSetAppWorkspaceContextFromUserContext } from '@/hooks/useSetAppWorkspaceContextFromUserContext'; import { useSetAppWorkspaceContextFromUserContext } from '@/hooks/useSetAppWorkspaceContextFromUserContext';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import type { BoxProps } from '@/ui/v2/Box'; import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
import { NextSeo } from 'next-seo'; import { NextSeo } from 'next-seo';
@@ -30,9 +31,15 @@ function ProjectLayoutContent({
...mainContainerProps ...mainContainerProps
} = {}, } = {},
}: ProjectLayoutProps) { }: ProjectLayoutProps) {
const { currentApplication, currentWorkspace } = // TODO: This will be removed once we migrated every occurrence to
// useCurrentWorkspaceAndProject()
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication(); useCurrentWorkspaceAndApplication();
const { currentProject, loading, error } = useCurrentWorkspaceAndProject({
fetchPolicy: 'network-only',
});
const router = useRouter(); const router = useRouter();
const shouldDisplayNav = useNavigationVisible(); const shouldDisplayNav = useNavigationVisible();
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
@@ -63,10 +70,14 @@ function ProjectLayoutContent({
} }
}, [isPlatform, isRestrictedPath, router]); }, [isPlatform, isRestrictedPath, router]);
if (!currentWorkspace || !currentApplication || isRestrictedPath) { if (!currentWorkspace || !currentApplication || isRestrictedPath || loading) {
return <LoadingScreen />; return <LoadingScreen />;
} }
if (error) {
throw error;
}
if (!isPlatform) { if (!isPlatform) {
return ( return (
<> <>
@@ -104,7 +115,7 @@ function ProjectLayoutContent({
> >
{children} {children}
<NextSeo title={currentApplication.name} /> <NextSeo title={currentProject.name} />
</Box> </Box>
</> </>
); );

View File

@@ -4,6 +4,7 @@ import { useDropdown } from '@/ui/v2/Dropdown';
import type { InputProps } from '@/ui/v2/Input'; import type { InputProps } from '@/ui/v2/Input';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import { format, set } from 'date-fns'; import { format, set } from 'date-fns';
import type { ChangeEvent } from 'react';
export interface LogTimePickerProps extends InputProps { export interface LogTimePickerProps extends InputProps {
/** /**
@@ -22,21 +23,21 @@ function LogsTimePicker({
}: any) { }: any) {
const { handleClose } = useDropdown(); const { handleClose } = useDropdown();
const handleCancel = () => { function handleCancel() {
handleClose(); handleClose();
}; }
const handleApply = () => { function handleApply() {
onChange(selectedDate); onChange(selectedDate);
handleClose(); handleClose();
}; }
const handleTimePicking = (event) => { function handleChange(event: ChangeEvent<HTMLInputElement>) {
const [hours, minutes, seconds] = event.target.value.split(':'); const [hours, minutes, seconds] = event.target.value?.split(':') || [];
const hoursNumber = parseInt(hours, 10); const hoursNumber = parseInt(hours || '0', 10);
const minutesNumber = parseInt(minutes, 10); const minutesNumber = parseInt(minutes || '0', 10);
const secondsNumber = parseInt(seconds, 10); const secondsNumber = parseInt(seconds || '0', 10);
const newDate = set(new Date(selectedDate), { const newDate = set(new Date(selectedDate), {
hours: hoursNumber, hours: hoursNumber,
@@ -51,7 +52,7 @@ function LogsTimePicker({
} }
setSelectedDate(newDate); setSelectedDate(newDate);
}; }
return ( return (
<div className="mx-auto grid grid-flow-row items-center self-center"> <div className="mx-auto grid grid-flow-row items-center self-center">
@@ -64,7 +65,7 @@ function LogsTimePicker({
formControl: { className: 'grid grid-flow-col gap-x-3' }, formControl: { className: 'grid grid-flow-col gap-x-3' },
label: { sx: { fontSize: '14px' } }, label: { sx: { fontSize: '14px' } },
}} }}
onChange={handleTimePicking} onChange={handleChange}
type="time" type="time"
label="Select Time" label="Select Time"
sx={{ sx={{

View File

@@ -0,0 +1,58 @@
import type { BoxProps } from '@/ui/v2/Box';
import Box from '@/ui/v2/Box';
import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import { InfoIcon } from '@/ui/v2/icons/InfoIcon';
import { twMerge } from 'tailwind-merge';
export interface MetricsCardProps extends BoxProps {
/**
* Label of the card.
*/
label?: string;
/**
* Value of the card.
*/
value?: string;
/**
* Tooltip of the card.
*/
tooltip?: string;
}
export default function MetricsCard({
label,
value,
tooltip,
className,
}: MetricsCardProps) {
return (
<Box
className={twMerge(
'grid grid-flow-row gap-2 rounded-md px-4 py-3',
className,
)}
sx={{ backgroundColor: 'grey.200' }}
>
<div className="grid grid-flow-col items-center justify-between gap-2">
{label && (
<Text className="truncate font-medium" color="secondary">
{label}
</Text>
)}
{tooltip && (
<Tooltip title={tooltip}>
<InfoIcon className="h-4 w-4" />
</Tooltip>
)}
</div>
{value && (
<Text variant="h2" component="p" className="truncate">
{value}
</Text>
)}
</Box>
);
}

View File

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

View File

@@ -2,8 +2,9 @@ import { UserDataProvider } from '@/context/workspace1-context';
import type { Project } from '@/types/application'; import type { Project } from '@/types/application';
import { ApplicationStatus } from '@/types/application'; import { ApplicationStatus } from '@/types/application';
import type { Workspace } from '@/types/workspace'; import type { Workspace } from '@/types/workspace';
import nhostGraphQLLink from '@/utils/msw/mocks/graphql/nhostGraphQLLink';
import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils'; import { render, screen, waitForElementToBeRemoved } from '@/utils/testUtils';
import { graphql, rest } from 'msw'; import { rest } from 'msw';
import { setupServer } from 'msw/node'; import { setupServer } from 'msw/node';
import { afterAll, beforeAll, vi } from 'vitest'; import { afterAll, beforeAll, vi } from 'vitest';
import OverviewDeployments from '.'; import OverviewDeployments from '.';
@@ -73,13 +74,11 @@ const mockWorkspace: Workspace = {
applications: [mockApplication], applications: [mockApplication],
}; };
const mockGraphqlLink = graphql.link('http://localhost:1337/v1/graphql');
const server = setupServer( const server = setupServer(
rest.get('http://localhost:1337/v1/graphql', (req, res, ctx) => rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
res(ctx.status(200)), res(ctx.status(200)),
), ),
mockGraphqlLink.operation(async (req, res, ctx) => nhostGraphQLLink.operation(async (_req, res, ctx) =>
res( res(
ctx.data({ ctx.data({
deployments: [], deployments: [],
@@ -143,7 +142,7 @@ test('should render an empty state when GitHub is connected, but there are no de
test('should render a list of deployments', async () => { test('should render a list of deployments', async () => {
server.use( server.use(
mockGraphqlLink.operation(async (req, res, ctx) => { nhostGraphQLLink.operation(async (req, res, ctx) => {
const requestPayload = await req.json(); const requestPayload = await req.json();
if (requestPayload.operationName === 'ScheduledOrPendingDeploymentsSub') { if (requestPayload.operationName === 'ScheduledOrPendingDeploymentsSub') {
@@ -193,7 +192,7 @@ test('should render a list of deployments', async () => {
test('should disable redeployments if a deployment is already in progress', async () => { test('should disable redeployments if a deployment is already in progress', async () => {
server.use( server.use(
mockGraphqlLink.operation(async (req, res, ctx) => { nhostGraphQLLink.operation(async (req, res, ctx) => {
const requestPayload = await req.json(); const requestPayload = await req.json();
if (requestPayload.operationName === 'ScheduledOrPendingDeploymentsSub') { if (requestPayload.operationName === 'ScheduledOrPendingDeploymentsSub') {

View File

@@ -35,7 +35,7 @@ export default function OverviewDocumentation({
<Text color="secondary">{description}</Text> <Text color="secondary">{description}</Text>
</div> </div>
<div className="mt-6 grid grid-flow-row items-center gap-6 xs:grid-cols-2 lg:grid-cols-4 lg:gap-4"> <div className="mt-6 grid grid-flow-row items-center gap-6 xs:grid-cols-2 lg:gap-4 xl:grid-cols-4">
{cardElements.map( {cardElements.map(
({ ({
title: cardTitle, title: cardTitle,

View File

@@ -0,0 +1,84 @@
import type { MetricsCardProps } from '@/components/overview/MetricsCard';
import { MetricsCard } from '@/components/overview/MetricsCard';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import Text from '@/ui/v2/Text';
import { useGetProjectMetricsQuery } from '@/utils/__generated__/graphql';
import { prettifyNumber } from '@/utils/common/prettifyNumber';
import { prettifySize } from '@/utils/common/prettifySize';
import { twMerge } from 'tailwind-merge';
const now = new Date();
export default function OverviewMetrics() {
const { currentProject } = useCurrentWorkspaceAndProject();
const { data, loading, error } = useGetProjectMetricsQuery({
variables: {
appId: currentProject?.id,
subdomain: currentProject?.subdomain,
from: new Date(now.getFullYear(), now.getMonth(), 1),
},
skip: !currentProject?.id,
});
const cardElements: MetricsCardProps[] = [
{
label: 'CPU Usage Seconds',
tooltip: 'Total time the service has used the CPUs',
value: prettifyNumber(data?.cpuSecondsUsage?.value || 0),
},
{
label: 'Total Requests',
tooltip:
'Total amount of requests your services have received excluding functions',
value: prettifyNumber(data?.totalRequests?.value || 0, {
numberOfDecimals: data?.totalRequests?.value > 1000 ? 2 : 0,
}),
},
{
label: 'Function Invocations',
tooltip: 'Number of times your functions have been called',
value: prettifyNumber(data?.functionInvocations?.value || 0, {
numberOfDecimals: 0,
}),
},
{
label: 'Egress Volume',
tooltip: 'Amount of data your services have sent to users',
value: prettifySize(data?.egressVolume?.value || 0),
},
{
label: 'Logs',
tooltip: 'Amount of logs stored',
value: prettifySize(data?.logsVolume?.value || 0),
},
];
if (!data && error) {
throw error;
}
return (
<div className="grid grid-flow-row gap-4">
<div className="grid grid-cols-1 justify-start gap-4 xs:grid-cols-2 md:grid-cols-3">
{cardElements.map(({ label, value, tooltip, className, ...props }) => (
<MetricsCard
{...props}
key={label}
label={!loading ? label : null}
value={!loading ? value : null}
tooltip={!loading ? tooltip : null}
className={twMerge(
'min-h-[92px]',
loading && 'animate-pulse',
className,
)}
/>
))}
</div>
<Text color="disabled">
Your resource usage since the beginning of the month.
</Text>
</div>
);
}

View File

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

View File

@@ -1,11 +1,11 @@
import InfoCard from '@/components/overview/InfoCard'; import InfoCard from '@/components/overview/InfoCard';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import Image from 'next/image'; import Image from 'next/image';
export default function OverviewProjectInfo() { export default function OverviewProjectInfo() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentProject } = useCurrentWorkspaceAndProject();
const { region, subdomain } = currentApplication || {}; const { region, subdomain } = currentProject || {};
const isRegionAvailable = const isRegionAvailable =
region?.awsName && region?.countryCode && region?.city; region?.awsName && region?.countryCode && region?.city;
@@ -13,14 +13,14 @@ export default function OverviewProjectInfo() {
<div className="grid grid-flow-row content-start gap-6"> <div className="grid grid-flow-row content-start gap-6">
<Text variant="h3">Project Info</Text> <Text variant="h3">Project Info</Text>
{currentApplication && ( {currentProject && (
<div className="grid grid-flow-row gap-3"> <div className="grid grid-flow-row gap-3">
<InfoCard <InfoCard
title="Region" title="Region"
value={region?.awsName} value={region?.awsName}
customValue={ customValue={
region.countryCode && region?.countryCode &&
region.city && ( region?.city && (
<div className="grid grid-flow-col items-center gap-1 self-center"> <div className="grid grid-flow-col items-center gap-1 self-center">
<Image <Image
src={`/assets/flags/${region.countryCode}.svg`} src={`/assets/flags/${region.countryCode}.svg`}
@@ -29,7 +29,7 @@ export default function OverviewProjectInfo() {
height={12} height={12}
/> />
<Text className="text-sm font-medium truncate"> <Text className="truncate text-sm font-medium">
{region.city} ({region.awsName}) {region.city} ({region.awsName})
</Text> </Text>
</div> </div>

View File

@@ -2,20 +2,19 @@ import { ChangePlanModal } from '@/components/applications/ChangePlanModal';
import { useDialog } from '@/components/common/DialogProvider'; import { useDialog } from '@/components/common/DialogProvider';
import { useUI } from '@/context/UIContext'; import { useUI } from '@/context/UIContext';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip'; import Chip from '@/ui/v2/Chip';
import CogIcon from '@/ui/v2/icons/CogIcon'; import CogIcon from '@/ui/v2/icons/CogIcon';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import Image from 'next/image'; import Image from 'next/image';
import Link from 'next/link'; import Link from 'next/link';
export default function OverviewTopBar() { export default function OverviewTopBar() {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const { currentWorkspace, currentApplication } = const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
useCurrentWorkspaceAndApplication(); const isPro = !currentProject?.plan?.isFree;
const isPro = !currentApplication?.plan?.isFree;
const { openAlertDialog } = useDialog(); const { openAlertDialog } = useDialog();
const { maintenanceActive } = useUI(); const { maintenanceActive } = useUI();
@@ -44,63 +43,92 @@ export default function OverviewTopBar() {
return ( return (
<div className="grid items-center gap-4 pb-5 md:grid-flow-col md:place-content-between md:py-5"> <div className="grid items-center gap-4 pb-5 md:grid-flow-col md:place-content-between md:py-5">
<div className="grid items-center gap-2 md:grid-flow-col"> <div className="grid items-center gap-4 md:grid-flow-col">
<div className="grid grid-flow-col items-center justify-start gap-2"> <div className="grid grid-flow-col items-center justify-start gap-2">
<div className="h-10 w-10 overflow-hidden rounded-lg"> <div className="h-10 w-10 overflow-hidden rounded-lg">
<Image <Image
src="/logos/new.svg" src="/logos/new.svg"
alt="Nhost Logo" alt="Nhost Logo"
width={40} width={56}
height={40} height={56}
/> />
</div> </div>
<Text variant="h2" component="h1"> <div className="grid grid-flow-row">
{currentApplication.name} <div className="grid grid-flow-row items-center justify-start md:grid-flow-col md:gap-3">
</Text> <Text
</div> variant="h2"
component="h1"
<Box className="grid grid-flow-col items-center justify-start gap-2"> className="grid grid-flow-col items-center gap-3"
{isPro ? (
<Chip
className="self-center font-medium"
size="small"
label="Pro Plan"
color="primary"
/>
) : (
<>
<Chip
className="self-center font-medium"
size="small"
label="Free Plan"
color="default"
variant="filled"
/>
<Button
variant="borderless"
className="mr-2"
onClick={() => {
openAlertDialog({
title: 'Upgrade your plan.',
payload: <ChangePlanModal />,
props: {
PaperProps: { className: 'p-0 max-w-xl w-full' },
hidePrimaryAction: true,
hideSecondaryAction: true,
hideTitle: true,
},
});
}}
> >
Upgrade {currentProject.name}
</Button> </Text>
</>
)} {currentProject.creator && (
</Box> <Text
color="secondary"
variant="subtitle2"
className="md:hidden"
>
Created by{' '}
{currentProject.creator?.displayName ||
currentProject.creator?.email}{' '}
{formatDistanceToNowStrict(
parseISO(currentProject.createdAt),
)}{' '}
ago
</Text>
)}
<div className="mt-1 inline-grid grid-flow-col items-center justify-start gap-2 md:mt-0">
<Chip
size="small"
label={isPro ? 'Pro' : 'Starter'}
color={isPro ? 'primary' : 'default'}
/>
{!isPro && (
<Button
variant="borderless"
className="mr-2"
onClick={() => {
openAlertDialog({
title: 'Upgrade your plan.',
payload: <ChangePlanModal />,
props: {
PaperProps: { className: 'p-0 max-w-xl w-full' },
hidePrimaryAction: true,
hideSecondaryAction: true,
hideTitle: true,
},
});
}}
>
Upgrade
</Button>
)}
</div>
</div>
{currentProject.creator && (
<Text
color="secondary"
variant="subtitle2"
className="hidden md:block"
>
Created by{' '}
{currentProject.creator?.displayName ||
currentProject.creator?.email}{' '}
{formatDistanceToNowStrict(parseISO(currentProject.createdAt))}{' '}
ago
</Text>
)}
</div>
</div>
</div> </div>
<Link <Link
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/general`} href={`/${currentWorkspace.slug}/${currentProject.slug}/settings/general`}
passHref passHref
> >
<Button <Button

View File

@@ -1,53 +1,55 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary'; import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import { import {
useGetAppFunctionsMetadataQuery, useGetAppFunctionsMetadataQuery,
useGetProjectMetricsQuery,
useGetRemoteAppMetricsQuery, useGetRemoteAppMetricsQuery,
} from '@/generated/graphql'; } from '@/generated/graphql';
import useIsPlatform from '@/hooks/common/useIsPlatform'; import useIsPlatform from '@/hooks/common/useIsPlatform';
import useDatabaseSizeOfApplication from '@/hooks/overview/useDatabaseSizeOfApplication';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient'; import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import LinearProgress from '@/ui/v2/LinearProgress'; import LinearProgress from '@/ui/v2/LinearProgress';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import prettysize from 'prettysize'; import { prettifySize } from '@/utils/common/prettifySize';
import { useEffect, useState } from 'react';
const now = new Date();
export interface UsageProgressProps { export interface UsageProgressProps {
/** /**
* The title of the current service being rendered. * The title of the current service being rendered.
*/ */
service: string; label: string;
/** /**
* The amount used for a given servince on the current project. * The amount used for a given servince on the current project.
*/ */
used: number; used?: string | number;
/** /**
* The total amount of a given service. * The total amount of a given service.
*/ */
total: number; total?: string | number;
/**
* The percentage of the service used.
*/
percentage?: number;
} }
export function UsageProgress({ service, used, total }: UsageProgressProps) { export function UsageProgress({
const denotesFileSizes = service === 'Database' || service === 'Storage'; label,
const normalizedTotal = denotesFileSizes ? total * 1024 * 1024 : total; used,
const percentage = Math.round((used / normalizedTotal) * 100); total,
const prettyTotal = denotesFileSizes percentage,
? prettysize(total * 1024 * 1024) }: UsageProgressProps) {
: total;
const prettyUsed = denotesFileSizes ? prettysize(used) : used;
return ( return (
<div className="flex flex-col space-y-3"> <div className="flex flex-col space-y-3">
<div className="flex flex-row place-content-between items-center"> <div className="flex flex-row place-content-between items-center">
<Text variant="subtitle2" className="lg:!font-medium"> <Text variant="subtitle2" className="lg:!font-medium">
{service} {label}
</Text> </Text>
<Text className="text-xs !font-medium"> <Text className="text-xs !font-medium">
{prettyUsed}{' '} {used} {total && <span className="opacity-80">of {total}</span>}
{total && <span className="opacity-80">of {prettyTotal}</span>}
</Text> </Text>
</div> </div>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={percentage === 0 ? -1 : percentage} value={percentage === 0 ? -1 : percentage}
@@ -56,92 +58,120 @@ export function UsageProgress({ service, used, total }: UsageProgressProps) {
); );
} }
const services = [
{
service: 'Database',
total: { Starter: 500, Pro: 10240 },
},
{
service: 'Storage',
total: { Starter: 1024, Pro: 10240 },
},
{
service: 'Users',
total: { Starter: 10000, Pro: 100000 },
},
{
service: 'Functions',
total: { Starter: 10, Pro: 50 },
},
];
export function OverviewUsageMetrics() { export function OverviewUsageMetrics() {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentProject } = useCurrentWorkspaceAndProject();
const remoteAppClient = useRemoteApplicationGQLClient(); const remoteAppClient = useRemoteApplicationGQLClient();
const [metrics, setMetrics] = useState({ const { data: functionsInfoData, loading: functionMetricsLoading } =
functions: 0, useGetAppFunctionsMetadataQuery({
storage: 0, variables: { id: currentProject?.id },
database: 0, skip: !isPlatform || !currentProject,
users: 0, });
});
const { data: functionsInfoData } = useGetAppFunctionsMetadataQuery({ const { data: projectMetrics, loading: projectMetricsLoading } =
variables: { id: currentApplication?.id }, useGetProjectMetricsQuery({
skip: !isPlatform, variables: {
}); appId: currentProject?.id,
subdomain: currentProject?.subdomain,
from: new Date(now.getFullYear(), now.getMonth(), 1),
},
skip: !isPlatform || !currentProject,
});
const { data: databaseSizeData } = useDatabaseSizeOfApplication( const { data: remoteAppMetricsData, loading: remoteAppMetricsLoading } =
[currentApplication?.name, 'databaseSize'], useGetRemoteAppMetricsQuery({
{ enabled: !!currentApplication }, client: remoteAppClient,
); skip: !currentProject,
});
const { data: remoteAppMetricsData } = useGetRemoteAppMetricsQuery({ const metricsLoading =
client: remoteAppClient, functionMetricsLoading || projectMetricsLoading || remoteAppMetricsLoading;
skip: !currentApplication, // metrics for database
}); const usedDatabase = projectMetrics?.postgresVolumeUsage.value || 0;
const totalDatabase = projectMetrics?.postgresVolumeCapacity.value || 0;
useEffect(() => { // metrics for storage
if (databaseSizeData) { const usedStorage =
setMetrics((m) => ({ remoteAppMetricsData?.filesAggregate?.aggregate?.sum?.size || 0;
...m, const totalStorage = currentProject?.plan?.isFree
database: databaseSizeData.databaseSize, ? 1 * 1000 ** 3 // 1 GB
})); : 10 * 1000 ** 3; // 10 GB
}
}, [databaseSizeData]);
useEffect(() => { // metrics for users
if (remoteAppMetricsData) { const usedUsers = remoteAppMetricsData?.usersAggregate?.aggregate?.count || 0;
setMetrics((m) => ({ const totalUsers = currentProject?.plan?.isFree ? 10000 : 100000;
...m,
storage: remoteAppMetricsData.filesAggregate.aggregate.sum.size,
users: remoteAppMetricsData.usersAggregate.aggregate.count,
}));
}
}, [remoteAppMetricsData]);
useEffect(() => { // metrics for functions
if (functionsInfoData) { const usedFunctions = functionsInfoData?.app.metadataFunctions.length || 0;
setMetrics((m) => ({ const totalFunctions = currentProject?.plan?.isFree ? 10 : 50;
...m,
functions: functionsInfoData.app.metadataFunctions.length, if (metricsLoading) {
})); return (
} <div className="grid grid-flow-row content-start gap-6">
}, [functionsInfoData]); <UsageProgress label="Database" percentage={0} />
<UsageProgress label="Storage" percentage={0} />
<UsageProgress label="Users" percentage={0} />
<UsageProgress label="Functions" percentage={0} />
</div>
);
}
if (!isPlatform) {
return (
<div className="grid grid-flow-row content-start gap-6">
<UsageProgress
label="Database"
used={prettifySize(0)}
percentage={100}
/>
<UsageProgress
label="Storage"
used={prettifySize(usedStorage)}
percentage={100}
/>
<UsageProgress label="Users" used={usedUsers} percentage={100} />
<UsageProgress
label="Functions"
used={usedFunctions}
percentage={100}
/>
</div>
);
}
return ( return (
<div className="grid grid-flow-row content-start gap-6"> <div className="grid grid-flow-row content-start gap-6">
{services.map((service) => ( <UsageProgress
<UsageProgress label="Database"
key={service.service} used={prettifySize(usedDatabase)}
service={service.service} total={prettifySize(totalDatabase)}
used={metrics[service.service.toLowerCase()]} percentage={(usedDatabase / totalDatabase) * 100}
total={ />
isPlatform ? service.total[currentApplication.plan?.name] : null
} <UsageProgress
/> label="Storage"
))} used={prettifySize(usedStorage)}
total={prettifySize(totalStorage)}
percentage={(usedStorage / totalStorage) * 100}
/>
<UsageProgress
label="Users"
used={usedUsers}
total={totalUsers}
percentage={(usedUsers / totalUsers) * 100}
/>
<UsageProgress
label="Functions"
used={usedFunctions}
total={totalFunctions}
percentage={(usedFunctions / totalFunctions) * 100}
/>
</div> </div>
); );
} }

View File

@@ -35,7 +35,7 @@ export default function DisableNewUsersSettings() {
const form = useForm<DisableNewUsersFormValues>({ const form = useForm<DisableNewUsersFormValues>({
reValidateMode: 'onSubmit', reValidateMode: 'onSubmit',
defaultValues: { defaultValues: {
disabled: !!data?.config?.auth?.signUp?.enabled, disabled: !data?.config?.auth?.signUp?.enabled,
}, },
}); });

View File

@@ -24,22 +24,30 @@ import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup'; import * as Yup from 'yup';
const validationSchema = Yup.object({ const validationSchema = Yup.object({
teamId: Yup.string().label('Team ID').when('enabled', { teamId: Yup.string()
is: true, .label('Team ID')
then: Yup.string().required(), .when('enabled', {
}), is: true,
keyId: Yup.string().label('Key ID').when('enabled', { then: (schema) => schema.required(),
is: true, }),
then: Yup.string().required(), keyId: Yup.string()
}), .label('Key ID')
clientId: Yup.string().label('Client ID').when('enabled', { .when('enabled', {
is: true, is: true,
then: Yup.string().required(), then: (schema) => schema.required(),
}), }),
privateKey: Yup.string().label('Private Key').when('enabled', { clientId: Yup.string()
is: true, .label('Client ID')
then: Yup.string().required(), .when('enabled', {
}), is: true,
then: (schema) => schema.required(),
}),
privateKey: Yup.string()
.label('Private Key')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
enabled: Yup.boolean(), enabled: Yup.boolean(),
}); });

View File

@@ -0,0 +1,201 @@
import Form from '@/components/common/Form';
import SettingsContainer from '@/components/settings/SettingsContainer';
import BaseProviderSettings from '@/components/settings/signInMethods/BaseProviderSettings';
import { useUI } from '@/context/UIContext';
import {
GetSignInMethodsDocument,
useGetSignInMethodsQuery,
useUpdateConfigMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import IconButton from '@/ui/v2/IconButton';
import CopyIcon from '@/ui/v2/icons/CopyIcon';
import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup';
const validationSchema = Yup.object({
clientId: Yup.string()
.label('Client ID')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
clientSecret: Yup.string()
.label('Client Secret')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
tenant: Yup.string()
.label('Tenant')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
enabled: Yup.boolean(),
});
export type AzureADProviderFormValues = Yup.InferType<typeof validationSchema>;
export default function AzureADProviderSettings() {
const { maintenanceActive } = useUI();
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateConfig] = useUpdateConfigMutation({
refetchQueries: [GetSignInMethodsDocument],
});
const { data, loading, error } = useGetSignInMethodsQuery({
variables: { appId: currentApplication?.id },
fetchPolicy: 'cache-only',
});
const { clientId, clientSecret, tenant, enabled } =
data?.config?.auth?.method?.oauth?.azuread || {};
const form = useForm<AzureADProviderFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
clientId: clientId || '',
clientSecret: clientSecret || '',
tenant: tenant || '',
enabled: enabled || false,
},
resolver: yupResolver(validationSchema),
});
if (loading) {
return (
<ActivityIndicator
delay={1000}
label="Loading settings for Azure AD..."
className="justify-center"
/>
);
}
if (error) {
throw error;
}
const { register, formState, watch } = form;
const authEnabled = watch('enabled');
const handleProviderUpdate = async (values: AzureADProviderFormValues) => {
const updateConfigPromise = updateConfig({
variables: {
appId: currentApplication.id,
config: {
auth: {
method: {
oauth: {
azuread: values,
},
},
},
},
},
});
try {
await toast.promise(
updateConfigPromise,
{
loading: `Azure AD settings are being updated...`,
success: `Azure AD settings have been updated successfully.`,
error: getServerError(
`An error occurred while trying to update the project's Azure AD settings.`,
),
},
getToastStyleProps(),
);
form.reset(values);
} catch {
// Note: The toast will handle the error.
}
};
return (
<FormProvider {...form}>
<Form onSubmit={handleProviderUpdate}>
<SettingsContainer
title="Azure AD"
description="Allow users to sign in with Azure AD."
slotProps={{
submitButton: {
disabled: !formState.isDirty || maintenanceActive,
loading: formState.isSubmitting,
},
}}
icon="/assets/brands/azuread.svg"
switchId="enabled"
showSwitch
className={twMerge(
'grid grid-flow-row grid-cols-2 gap-y-4 gap-x-3 px-4 py-2',
!authEnabled && 'hidden',
)}
>
<BaseProviderSettings providerName="azuread" />
<Input
{...register('tenant')}
name="tenant"
id="tenant"
label="Tenant ID"
placeholder="Tenant ID"
className="col-span-2"
fullWidth
hideEmptyHelperText
error={!!formState.errors?.tenant}
helperText={formState.errors?.tenant?.message}
/>
<Input
name="redirectUrl"
id="redirectUrl"
defaultValue={`${generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'auth',
)}/signin/provider/azuread/callback`}
className="col-span-2"
fullWidth
hideEmptyHelperText
label="Redirect URL"
disabled
endAdornment={
<InputAdornment position="end" className="absolute right-2">
<IconButton
sx={{ minWidth: 0, padding: 0 }}
color="secondary"
variant="borderless"
onClick={(e) => {
e.stopPropagation();
copy(
`${generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'auth',
)}/signin/provider/azuread/callback`,
'Redirect URL',
);
}}
>
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}
/>
</SettingsContainer>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -3,14 +3,18 @@ import { useFormContext } from 'react-hook-form';
import * as Yup from 'yup'; import * as Yup from 'yup';
export const baseProviderValidationSchema = Yup.object({ export const baseProviderValidationSchema = Yup.object({
clientId: Yup.string().label('Client ID').when('enabled', { clientId: Yup.string()
is: true, .label('Client ID')
then: Yup.string().required(), .when('enabled', {
}), is: true,
clientSecret: Yup.string().label('Client Secret').when('enabled', { then: (schema) => schema.required(),
is: true, }),
then: Yup.string().required(), clientSecret: Yup.string()
}), .label('Client Secret')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
enabled: Yup.bool(), enabled: Yup.bool(),
}); });

View File

@@ -22,19 +22,23 @@ import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup'; import * as Yup from 'yup';
const validationSchema = Yup.object({ const validationSchema = Yup.object({
accountSid: Yup.string().label('Account SID').when('enabled', { accountSid: Yup.string()
is: true, .label('Account SID')
then: Yup.string().required(), .when('enabled', {
}), is: true,
authToken: Yup.string().label('Auth Token').when('enabled', { then: (schema) => schema.required(),
is: true, }),
then: Yup.string().required(), authToken: Yup.string()
}), .label('Auth Token')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
messagingServiceId: Yup.string() messagingServiceId: Yup.string()
.label('Messaging Service ID') .label('Messaging Service ID')
.when('enabled', { .when('enabled', {
is: true, is: true,
then: Yup.string().required(), then: (schema) => schema.required(),
}), }),
enabled: Yup.boolean().label('Enabled'), enabled: Yup.boolean().label('Enabled'),
}); });

View File

@@ -23,14 +23,18 @@ import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup'; import * as Yup from 'yup';
const validationSchema = Yup.object({ const validationSchema = Yup.object({
consumerSecret: Yup.string().label('Consumer Secret').when('enabled', { consumerSecret: Yup.string()
is: true, .label('Consumer Secret')
then: Yup.string().required(), .when('enabled', {
}), is: true,
consumerKey: Yup.string().label('Consumer Key').when('enabled', { then: (schema) => schema.required(),
is: true, }),
then: Yup.string().required(), consumerKey: Yup.string()
}), .label('Consumer Key')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
enabled: Yup.boolean(), enabled: Yup.boolean(),
}); });

View File

@@ -24,22 +24,30 @@ import { twMerge } from 'tailwind-merge';
import * as Yup from 'yup'; import * as Yup from 'yup';
const validationSchema = Yup.object({ const validationSchema = Yup.object({
clientId: Yup.string().label('Client ID').when('enabled', { clientId: Yup.string()
is: true, .label('Client ID')
then: Yup.string().required(), .when('enabled', {
}), is: true,
clientSecret: Yup.string().label('Client Secret').when('enabled', { then: (schema) => schema.required(),
is: true, }),
then: Yup.string().required(), clientSecret: Yup.string()
}), .label('Client Secret')
organization: Yup.string().label('Organization').when('enabled', { .when('enabled', {
is: true, is: true,
then: Yup.string().required(), then: (schema) => schema.required(),
}), }),
connection: Yup.string().label('Connection').when('enabled', { organization: Yup.string()
is: true, .label('Organization')
then: Yup.string().required(), .when('enabled', {
}), is: true,
then: (schema) => schema.required(),
}),
connection: Yup.string()
.label('Connection')
.when('enabled', {
is: true,
then: (schema) => schema.required(),
}),
enabled: Yup.boolean(), enabled: Yup.boolean(),
}); });

View File

@@ -5,7 +5,11 @@ export interface StateBadgeProps {
/** /**
* This is the current state of the application. * This is the current state of the application.
*/ */
status: ApplicationStatus; state: ApplicationStatus;
/**
* This is the desired state of the application.
*/
desiredState: ApplicationStatus;
/** /**
* The title to show on the application state badge. * The title to show on the application state badge.
*/ */
@@ -24,20 +28,28 @@ function getNormalizedTitle(title: string) {
return title; return title;
} }
export default function StateBadge({ title, status }: StateBadgeProps) { export default function StateBadge({
title,
state,
desiredState,
}: StateBadgeProps) {
if (
desiredState === ApplicationStatus.Paused &&
state === ApplicationStatus.Live
) {
return <Chip size="small" color="default" label="Pausing" />;
}
const normalizedTitle = getNormalizedTitle(title); const normalizedTitle = getNormalizedTitle(title);
if ( if (
status === ApplicationStatus.Empty || state === ApplicationStatus.Empty ||
status === ApplicationStatus.Unpausing state === ApplicationStatus.Unpausing
) { ) {
return <Chip size="small" label={normalizedTitle} color="warning" />; return <Chip size="small" label={normalizedTitle} color="warning" />;
} }
if ( if (state === ApplicationStatus.Errored || state === ApplicationStatus.Live) {
status === ApplicationStatus.Errored ||
status === ApplicationStatus.Live
) {
return <Chip size="small" label={normalizedTitle} color="success" />; return <Chip size="small" label={normalizedTitle} color="success" />;
} }

View File

@@ -11,7 +11,7 @@ const StyledListItemText = styled(MaterialListItemText)(({ theme }) => ({
display: 'grid', display: 'grid',
justifyContent: 'start', justifyContent: 'start',
gridAutoFlow: 'row', gridAutoFlow: 'row',
gap: theme.spacing(0.5), gap: theme.spacing(0.25),
fontSize: theme.typography.pxToRem(15), fontSize: theme.typography.pxToRem(15),
[`&.${listItemTextClasses.root}`]: { [`&.${listItemTextClasses.root}`]: {
margin: 0, margin: 0,

View File

@@ -6,7 +6,7 @@ import SvgIcon from '@/ui/v2/icons/SvgIcon';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import type { RadioProps as MaterialRadioProps } from '@mui/material/Radio'; import type { RadioProps as MaterialRadioProps } from '@mui/material/Radio';
import MaterialRadio from '@mui/material/Radio'; import MaterialRadio from '@mui/material/Radio';
import type { ForwardedRef, PropsWithoutRef } from 'react'; import type { ForwardedRef, PropsWithoutRef, ReactNode } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
export interface RadioProps extends MaterialRadioProps { export interface RadioProps extends MaterialRadioProps {
@@ -17,7 +17,7 @@ export interface RadioProps extends MaterialRadioProps {
/** /**
* Label to be displayed next to the radio button. * Label to be displayed next to the radio button.
*/ */
label?: string; label?: ReactNode;
/** /**
* Props to be passed to individual component slots. * Props to be passed to individual component slots.
*/ */

View File

@@ -1,7 +1,9 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import type { TooltipProps as MaterialTooltipProps } from '@mui/material/Tooltip'; import type { TooltipProps as MaterialTooltipProps } from '@mui/material/Tooltip';
import MaterialTooltip, { tooltipClasses } from '@mui/material/Tooltip'; import MaterialTooltip, {
tooltipClasses as materialTooltipClasses,
} from '@mui/material/Tooltip';
import type { ForwardedRef } from 'react'; import type { ForwardedRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
@@ -21,24 +23,38 @@ export interface TooltipProps extends MaterialTooltipProps {
} }
const StyledTooltip = styled(Box)(({ theme }) => ({ const StyledTooltip = styled(Box)(({ theme }) => ({
[`&.${tooltipClasses.tooltip}`]: { [`&.${materialTooltipClasses.tooltip}`]: {
fontSize: '0.9375rem', fontSize: '0.9375rem',
lineHeight: '1.375rem', lineHeight: '1.375rem',
backgroundColor: backgroundColor:
theme.palette.mode === 'dark' theme.palette.mode === 'dark'
? theme.palette.grey[300] ? theme.palette.grey[400]
: theme.palette.grey[700], : theme.palette.grey[700],
color: theme.palette.common.white, color: theme.palette.common.white,
padding: theme.spacing(0.5, 1), padding: theme.spacing(0.75, 1.25),
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
WebkitFontSmoothing: 'antialiased', WebkitFontSmoothing: 'antialiased',
boxShadow: boxShadow:
'0px 1px 4px rgba(14, 24, 39, 0.1), 0px 8px 24px rgba(14, 24, 39, 0.1)', '0px 1px 4px rgba(14, 24, 39, 0.1), 0px 8px 24px rgba(14, 24, 39, 0.1)',
maxWidth: '17.5rem', maxWidth: '17.5rem',
}, },
[`&.${tooltipClasses.tooltipPlacementBottom}`]: { [`& .${materialTooltipClasses.arrow}`]: {
color:
theme.palette.mode === 'dark'
? theme.palette.grey[300]
: theme.palette.grey[700],
},
[`&.${materialTooltipClasses.tooltipPlacementBottom}`]: {
marginTop: `${theme.spacing(0.75)} !important`, marginTop: `${theme.spacing(0.75)} !important`,
}, },
[`&.${materialTooltipClasses.tooltipPlacementBottom} .${materialTooltipClasses.arrow}`]:
{
marginTop: `${theme.spacing(-0.5)} !important`,
color:
theme.palette.mode === 'dark'
? theme.palette.grey[300]
: theme.palette.grey[700],
},
})); }));
function Tooltip( function Tooltip(
@@ -69,6 +85,8 @@ function Tooltip(
); );
} }
export { materialTooltipClasses as tooltipClasses };
Tooltip.displayName = 'NhostTooltip'; Tooltip.displayName = 'NhostTooltip';
export default forwardRef(Tooltip); export default forwardRef(Tooltip);

View File

@@ -0,0 +1,34 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@/ui/v2/icons/SvgIcon';
import type { ForwardedRef } from 'react';
import { forwardRef } from 'react';
function InfoIcon(props: IconProps, ref: ForwardedRef<SVGSVGElement>) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="Info"
ref={ref}
{...props}
>
<path
opacity="0.2"
d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Z"
fill="currentColor"
/>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM6.5 8.25h.75V11c0 .414.336.75.75.75h1.5v-1.5h-.75V7.5A.75.75 0 0 0 8 6.75H6.5v1.5Z"
fill="currentColor"
/>
</SvgIcon>
);
}
InfoIcon.displayName = 'NhostInfoIcon';
export default forwardRef(InfoIcon);

View File

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

View File

@@ -32,7 +32,6 @@ export const validationSchema = Yup.object({
.required('This field is required.'), .required('This field is required.'),
password: Yup.string() password: Yup.string()
.label('Users Password') .label('Users Password')
.min(8, 'Password must be at least 8 characters long.')
.required('This field is required.'), .required('This field is required.'),
}); });
@@ -99,7 +98,7 @@ export default function CreateUserForm({
}), }),
{ {
loading: 'Creating user...', loading: 'Creating user...',
success: 'User created successfully.', success: 'User has been created successfully.',
error: getServerError( error: getServerError(
'An error occurred while trying to create the user.', 'An error occurred while trying to create the user.',
), ),
@@ -165,7 +164,7 @@ export default function CreateUserForm({
</Alert> </Alert>
)} )}
<div className="grid grid-flow-row gap-2"> <div className="grid grid-flow-row gap-2">
<Button type="submit" loading={isSubmitting} disabled={isSubmitting}> <Button type="submit" disabled={isSubmitting}>
Create Create
</Button> </Button>

View File

@@ -19,6 +19,7 @@ import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import getReadableProviderName from '@/utils/common/getReadableProviderName'; import getReadableProviderName from '@/utils/common/getReadableProviderName';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import getServerError from '@/utils/settings/getServerError';
import getUserRoles from '@/utils/settings/getUserRoles'; import getUserRoles from '@/utils/settings/getUserRoles';
import { getToastStyleProps } from '@/utils/settings/settingsConstants'; import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { import {
@@ -168,11 +169,13 @@ export default function EditUserForm({
{ {
loading: shouldBan ? 'Banning user...' : 'Unbanning user...', loading: shouldBan ? 'Banning user...' : 'Unbanning user...',
success: shouldBan success: shouldBan
? 'User banned successfully' ? 'User has been banned successfully.'
: 'User unbanned successfully.', : 'User has been unbanned successfully.',
error: shouldBan error: getServerError(
? 'An error occurred while trying to ban the user.' shouldBan
: 'An error occurred while trying to unban the user.', ? 'An error occurred while trying to ban the user.'
: 'An error occurred while trying to unban the user.',
),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
@@ -213,7 +216,7 @@ export default function EditUserForm({
Actions Actions
</Button> </Button>
</Dropdown.Trigger> </Dropdown.Trigger>
<Dropdown.Content menu disablePortal className="h-full w-full"> <Dropdown.Content menu className="h-full w-full">
<Dropdown.Item <Dropdown.Item
className="font-medium" className="font-medium"
sx={{ color: 'error.main' }} sx={{ color: 'error.main' }}
@@ -316,6 +319,7 @@ export default function EditUserForm({
id="emailVerified" id="emailVerified"
name="emailVerified" name="emailVerified"
label="Verified" label="Verified"
aria-label="Email Verified"
/> />
) )
} }
@@ -354,6 +358,7 @@ export default function EditUserForm({
id="phoneNumberVerified" id="phoneNumberVerified"
name="phoneNumberVerified" name="phoneNumberVerified"
label="Verified" label="Verified"
aria-label="Phone Number Verified"
disabled={!form.watch('phoneNumber')} disabled={!form.watch('phoneNumber')}
/> />
) )

View File

@@ -151,7 +151,7 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
updateUserMutationPromise, updateUserMutationPromise,
{ {
loading: `Updating user's settings...`, loading: `Updating user's settings...`,
success: 'User settings updated successfully.', success: 'User settings have been updated successfully.',
error: getServerError( error: getServerError(
`An error occurred while trying to update this user's settings.`, `An error occurred while trying to update this user's settings.`,
), ),
@@ -242,7 +242,11 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
secondaryAction={ secondaryAction={
<Dropdown.Root> <Dropdown.Root>
<Dropdown.Trigger asChild hideChevron> <Dropdown.Trigger asChild hideChevron>
<IconButton variant="borderless" color="secondary"> <IconButton
variant="borderless"
color="secondary"
aria-label={`More options for ${user.displayName}`}
>
<DotsHorizontalIcon /> <DotsHorizontalIcon />
</IconButton> </IconButton>
</Dropdown.Trigger> </Dropdown.Trigger>
@@ -282,6 +286,7 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
<ListItem.Button <ListItem.Button
className="grid h-full w-full grid-cols-1 py-2.5 lg:grid-cols-6" className="grid h-full w-full grid-cols-1 py-2.5 lg:grid-cols-6"
onClick={() => handleViewUser(user)} onClick={() => handleViewUser(user)}
aria-label={`View ${user.displayName}`}
> >
<div className="col-span-2 grid grid-flow-col place-content-start gap-4"> <div className="col-span-2 grid grid-flow-col place-content-start gap-4">
<Avatar <Avatar

View File

@@ -1,21 +1,31 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import Status, { StatusEnum } from '@/ui/Status'; import { useCurrentWorkspaceAndProject } from '@/hooks/v2/useCurrentWorkspaceAndProject';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider'; import Divider from '@/ui/v2/Divider';
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon'; import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
import List from '@/ui/v2/List'; import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem'; import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import Image from 'next/image'; import Image from 'next/image';
import NavLink from 'next/link'; import NavLink from 'next/link';
import { Fragment } from 'react'; import { Fragment } from 'react';
function AllWorkspaceApps() { function AllWorkspaceApps() {
const { currentWorkspace } = useCurrentWorkspaceAndApplication(); const { currentWorkspace, loading, error } = useCurrentWorkspaceAndProject();
const noApplications = currentWorkspace?.applications.length === 0;
if (noApplications) { if (loading) {
return <ActivityIndicator label="Loading projects..." delay={1000} />;
}
if (error) {
throw error;
}
if (currentWorkspace?.projects?.length === 0) {
return ( return (
<Box className="flex flex-row border-y py-4"> <Box className="flex flex-row border-y py-4">
<Text className="text-xs" color="secondary"> <Text className="text-xs" color="secondary">
@@ -29,25 +39,50 @@ function AllWorkspaceApps() {
<List> <List>
<Divider component="li" /> <Divider component="li" />
{currentWorkspace?.applications.map((app) => ( {currentWorkspace?.projects.map((project) => (
<Fragment key={app.id}> <Fragment key={project.id}>
<ListItem.Root> <ListItem.Root>
<NavLink href={`${currentWorkspace?.slug}/${app.slug}`} passHref> <NavLink
<ListItem.Button className="grid grid-flow-col justify-between gap-2"> href={`${currentWorkspace?.slug}/${project.slug}`}
<div className="grid grid-flow-col items-center gap-2"> passHref
<div className="h-8 w-8 overflow-hidden rounded-lg"> >
<Image <ListItem.Button className="grid grid-flow-col items-center justify-between gap-2">
src="/logos/new.svg" <div className="grid grid-flow-col items-center justify-start gap-2">
alt="Nhost Logo" <ListItem.Avatar>
width={32} <div className="h-8 w-8 overflow-hidden rounded-lg">
height={32} <Image
/> src="/logos/new.svg"
</div> alt="Nhost Logo"
width={32}
height={32}
/>
</div>
</ListItem.Avatar>
<Text className="font-medium">{app.name}</Text> <ListItem.Text
primary={project.name}
secondary={
project.creator ? (
<span>
{`Created by ${
project.creator.displayName || project.creator.email
} ${formatDistanceToNowStrict(
parseISO(project.createdAt),
)} ago`}
</span>
) : undefined
}
secondaryTypographyProps={{
className: 'text-xs',
}}
/>
</div> </div>
<Status status={StatusEnum.Plan}>{app.plan.name}</Status> <Chip
size="small"
label={project.plan.isFree ? 'Starter' : 'Pro'}
color={project.plan.isFree ? 'default' : 'primary'}
/>
</ListItem.Button> </ListItem.Button>
</NavLink> </NavLink>
</ListItem.Root> </ListItem.Root>
@@ -59,30 +94,35 @@ function AllWorkspaceApps() {
); );
} }
export default function WorkspaceApps() { export default function WorkspaceApps() {
const { currentWorkspace } = useCurrentWorkspaceAndApplication(); const { currentWorkspace, loading } = useCurrentWorkspaceAndProject();
return ( return (
<div className="mt-9"> <div className="mt-9">
<div className="mx-auto max-w-3xl font-display"> <div className="mx-auto max-w-3xl font-display">
<div className="mb-4 flex flex-row place-content-between"> <div className="mb-4 grid grid-flow-col items-center justify-between gap-2">
<Text className="text-lg font-medium">Projects</Text> <Text className="text-lg font-medium">Projects</Text>
<NavLink
href={{ {!loading && (
pathname: '/new', <NavLink
query: { workspace: currentWorkspace.slug }, href={{
}} pathname: '/new',
> query: { workspace: currentWorkspace?.slug },
<Button }}
variant="outlined"
color="secondary"
startIcon={<PlusCircleIcon />}
> >
New Project <Button
</Button> variant="outlined"
</NavLink> color="secondary"
startIcon={<PlusCircleIcon />}
>
New Project
</Button>
</NavLink>
)}
</div> </div>
<AllWorkspaceApps /> <RetryableErrorBoundary errorMessageProps={{ className: 'px-0' }}>
<AllWorkspaceApps />
</RetryableErrorBoundary>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,37 @@
query GetProjectMetrics(
$appId: String!
$subdomain: String!
$from: Timestamp
$to: Timestamp
) {
logsVolume: getLogsVolume(appID: $appId, from: $from, to: $to) {
value
}
cpuSecondsUsage: getCPUSecondsUsage(appID: $appId, from: $from, to: $to) {
value
}
functionInvocations: getFunctionsInvocations(
appID: $appId
from: $from
to: $to
) {
value
}
postgresVolumeCapacity: getPostgresVolumeCapacity(appID: $appId) {
value
}
postgresVolumeUsage: getPostgresVolumeUsage(appID: $appId) {
value
}
totalRequests: getTotalRequests(appID: $appId, from: $from, to: $to) {
value
}
egressVolume: getEgressVolume(
appID: $appId
subdomain: $subdomain
from: $from
to: $to
) {
value
}
}

View File

@@ -0,0 +1,26 @@
fragment Workspace on workspaces {
id
name
slug
workspaceMembers {
id
user {
id
email
displayName
}
type
}
projects: apps {
...Project
}
}
query GetWorkspaceAndProject($workspaceSlug: String!, $projectSlug: String) {
workspaces(where: { slug: { _eq: $workspaceSlug } }) {
...Workspace
}
projects: apps(where: { slug: { _eq: $projectSlug } }) {
...Project
}
}

View File

@@ -0,0 +1,5 @@
mutation PauseApplication($appId: uuid!) {
updateApp(pk_columns: { id: $appId }, _set: { desiredState: 6 }) {
id
}
}

View File

@@ -100,6 +100,12 @@ query GetSignInMethods($appId: uuid!) {
connection connection
organization organization
} }
azuread {
enabled
clientId
clientSecret
tenant
}
} }
} }
} }

View File

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

View File

@@ -0,0 +1,59 @@
fragment Project on apps {
id
slug
name
repositoryProductionBranch
subdomain
isProvisioned
createdAt
desiredState
nhostBaseFolder
providersUpdated
config(resolve: true) {
hasura {
adminSecret
}
}
featureFlags {
description
id
name
value
}
appStates(order_by: { createdAt: desc }, limit: 1) {
id
appId
message
stateId
createdAt
}
region {
id
countryCode
awsName
city
}
plan {
id
name
isFree
}
githubRepository {
fullName
}
deployments(limit: 4, order_by: { deploymentEndedAt: desc }) {
id
commitSHA
commitMessage
commitUserName
deploymentStartedAt
deploymentEndedAt
commitUserAvatarUrl
deploymentStatus
}
creator {
id
email
displayName
}
}

View File

@@ -0,0 +1,11 @@
query GetFreeAndActiveProjects($userId: uuid!) {
freeAndActiveProjects: apps(
where: {
creatorUserId: { _eq: $userId }
plan: { isFree: { _eq: true } }
desiredState: { _eq: 5 }
}
) {
id
}
}

View File

@@ -1,58 +1,3 @@
fragment Project on apps {
id
slug
name
repositoryProductionBranch
subdomain
isProvisioned
createdAt
desiredState
nhostBaseFolder
providersUpdated
config(resolve: true) {
hasura {
adminSecret
}
}
featureFlags {
description
id
name
value
}
appStates(order_by: { createdAt: desc }, limit: 1) {
id
appId
message
stateId
createdAt
}
region {
id
countryCode
awsName
city
}
plan {
id
name
isFree
}
githubRepository {
fullName
}
deployments(limit: 4, order_by: { deploymentEndedAt: desc }) {
id
commitSHA
commitMessage
commitUserName
deploymentStartedAt
deploymentEndedAt
commitUserAvatarUrl
deploymentStatus
}
}
query getOneUser($userId: uuid!) { query getOneUser($userId: uuid!) {
user(id: $userId) { user(id: $userId) {
id id

View File

@@ -5,23 +5,29 @@ import { useCurrentWorkspaceAndApplication } from './useCurrentWorkspaceAndAppli
* This hook returns the current application state. If the application state * This hook returns the current application state. If the application state
* has not been filled, it returns an Empty application status. * has not been filled, it returns an Empty application status.
*/ */
export default function useApplicationState(): ApplicationStatus { export default function useApplicationState(): {
state: ApplicationStatus;
message?: string;
} {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const noApplication = !currentApplication; const noApplication = !currentApplication;
if (noApplication) { if (noApplication) {
return ApplicationStatus.Empty; return { state: ApplicationStatus.Empty };
} }
const emptyApplicationStates = !currentApplication.appStates; const emptyApplicationStates = !currentApplication.appStates;
if (noApplication || emptyApplicationStates) { if (noApplication || emptyApplicationStates) {
return ApplicationStatus.Empty; return { state: ApplicationStatus.Empty };
} }
if (currentApplication.appStates?.length === 0) { if (currentApplication.appStates?.length === 0) {
return ApplicationStatus.Empty; return { state: ApplicationStatus.Empty };
} }
return currentApplication.appStates[0].stateId; return {
state: currentApplication.appStates[0].stateId,
message: currentApplication.appStates[0].message,
};
} }

View File

@@ -39,6 +39,8 @@ export function useCheckProvisioning() {
const memoizedUpdateCache = useCallback(updateOwnCache, [client]); const memoizedUpdateCache = useCallback(updateOwnCache, [client]);
const currentApplicationId = currentApplication?.id;
useEffect(() => { useEffect(() => {
startPolling(2000); startPolling(2000);
}, [startPolling]); }, [startPolling]);
@@ -84,7 +86,7 @@ export function useCheckProvisioning() {
createdAt: data.app.appStates[0].createdAt, createdAt: data.app.appStates[0].createdAt,
}); });
discordAnnounce( discordAnnounce(
`Application ${currentApplication.id} errored after provisioning: ${data.app.appStates[0].message}`, `Application ${currentApplicationId} errored after provisioning: ${data.app.appStates[0].message}`,
); );
stopPolling(); stopPolling();
memoizedUpdateCache(); memoizedUpdateCache();
@@ -93,7 +95,7 @@ export function useCheckProvisioning() {
data, data,
stopPolling, stopPolling,
memoizedUpdateCache, memoizedUpdateCache,
currentApplication.id, currentApplicationId,
currentApplicationState.state, currentApplicationState.state,
]); ]);

View File

@@ -9,7 +9,7 @@ import usePreviousApplicationState from './usePreviousApplicationStates';
*/ */
export function useNavigationVisible() { export function useNavigationVisible() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const applicationState = useApplicationState(); const { state } = useApplicationState();
const previousApplicationState = usePreviousApplicationState(); const previousApplicationState = usePreviousApplicationState();
const router = useRouter(); const router = useRouter();
@@ -39,14 +39,14 @@ export function useNavigationVisible() {
} }
if ( if (
applicationState === ApplicationStatus.Live || state === ApplicationStatus.Live ||
applicationState === ApplicationStatus.Updating state === ApplicationStatus.Updating
) { ) {
return true; return true;
} }
if ( if (
applicationState === ApplicationStatus.Errored && state === ApplicationStatus.Errored &&
previousApplicationState === ApplicationStatus.Updating previousApplicationState === ApplicationStatus.Updating
) { ) {
return true; return true;

View File

@@ -11,7 +11,7 @@ export default function useNotFoundRedirect() {
const router = useRouter(); const router = useRouter();
const { const {
query: { workspaceSlug, appSlug, updating }, query: { workspaceSlug, appSlug, updating },
} = useRouter(); } = router;
const notIn404Already = router.pathname !== '/404'; const notIn404Already = router.pathname !== '/404';
const noResolvedWorkspace = workspaceSlug && currentWorkspace === undefined; const noResolvedWorkspace = workspaceSlug && currentWorkspace === undefined;

View File

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

View File

@@ -0,0 +1,115 @@
import useIsPlatform from '@/hooks/common/useIsPlatform';
import type { Project, Workspace } from '@/types/application';
import { ApplicationStatus } from '@/types/application';
import type { GetWorkspaceAndProjectQueryHookResult } from '@/utils/__generated__/graphql';
import { useGetWorkspaceAndProjectQuery } from '@/utils/__generated__/graphql';
import { getHasuraAdminSecret } from '@/utils/env';
import type { QueryHookOptions } from '@apollo/client';
import { useRouter } from 'next/router';
export interface UseCurrentWorkspaceAndProjectOptions {
/**
* The fetch policy to use.
*
* @default 'cache-first'
*/
fetchPolicy?: QueryHookOptions['fetchPolicy'];
}
export interface UseCurrentWorkspaceAndProjectReturnType {
/**
* The current workspace.
*/
currentWorkspace: Workspace;
/**
* The current project.
*/
currentProject: Project;
/**
* Whether the query is loading.
*/
loading?: GetWorkspaceAndProjectQueryHookResult['loading'];
/**
* The error if any.
*/
error?: GetWorkspaceAndProjectQueryHookResult['error'];
}
export default function useCurrentWorkspaceAndProject(
options?: UseCurrentWorkspaceAndProjectOptions,
): UseCurrentWorkspaceAndProjectReturnType {
const isPlatform = useIsPlatform();
const {
query: { workspaceSlug, appSlug },
isReady,
} = useRouter();
const { data, loading, error } = useGetWorkspaceAndProjectQuery({
variables: {
workspaceSlug: (workspaceSlug as string) || '',
projectSlug: (appSlug as string) || '',
},
fetchPolicy: options?.fetchPolicy || 'cache-first',
skip: !isPlatform || !isReady || !workspaceSlug,
});
if (!isPlatform) {
const localProject: Project = {
id: 'local',
slug: 'local',
name: 'local',
appStates: [
{
id: 'local',
appId: 'local',
stateId: ApplicationStatus.Live,
createdAt: new Date().toISOString(),
},
],
deployments: [],
subdomain: 'local',
region: {
id: null,
countryCode: null,
city: null,
awsName: null,
},
isProvisioned: true,
createdAt: new Date().toISOString(),
desiredState: ApplicationStatus.Live,
featureFlags: [],
providersUpdated: true,
repositoryProductionBranch: null,
nhostBaseFolder: null,
plan: null,
config: {
hasura: {
adminSecret: getHasuraAdminSecret(),
},
},
};
return {
currentWorkspace: {
id: 'local',
slug: 'local',
name: 'local',
projects: [localProject],
workspaceMembers: [],
},
currentProject: localProject,
loading: false,
};
}
const [currentWorkspace] = data?.workspaces || [];
const [currentProject] = data?.projects || [];
return {
currentWorkspace,
currentProject,
loading: data ? false : loading,
error,
};
}

View File

@@ -11,8 +11,8 @@ import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip'; import Chip from '@/ui/v2/Chip';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import { prettifySize } from '@/utils/common/prettifySize';
import { formatDistanceStrict, formatISO9075 } from 'date-fns'; import { formatDistanceStrict, formatISO9075 } from 'date-fns';
import prettysize from 'prettysize';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { useState } from 'react'; import { useState } from 'react';
@@ -58,13 +58,13 @@ function BackupRow({ backup }: any) {
/> />
)} )}
<Box className="flex flex-row place-content-between py-3"> <Box className="flex flex-row place-content-between py-3">
<Text className="w-drop self-center font-medium text-xs"> <Text className="w-drop self-center text-xs font-medium">
{formatISO9075(new Date(createdAt))} {formatISO9075(new Date(createdAt))}
</Text> </Text>
<Text className="w-drop self-center font-medium text-xs"> <Text className="w-drop self-center text-xs font-medium">
{prettysize(size)} {prettifySize(size)}
</Text> </Text>
<Text className="w-drop self-center font-medium text-xs"> <Text className="w-drop self-center text-xs font-medium">
{formatDistanceStrict(new Date(createdAt), new Date(), { {formatDistanceStrict(new Date(createdAt), new Date(), {
addSuffix: true, addSuffix: true,
})} })}
@@ -108,9 +108,9 @@ function BackupsTable() {
return ( return (
<> <>
<Box className="flex flex-row place-content-between border-b-1 py-2"> <Box className="flex flex-row place-content-between border-b-1 py-2">
<Text className="w-drop font-bold text-xs">Backup</Text> <Text className="w-drop text-xs font-bold">Backup</Text>
<Text className="w-drop font-bold text-xs">Size</Text> <Text className="w-drop text-xs font-bold">Size</Text>
<Text className="w-drop font-bold text-xs">Backed Up</Text> <Text className="w-drop text-xs font-bold">Backed Up</Text>
<div className="w-20" /> <div className="w-20" />
</Box> </Box>
<Box className="border-b-1"> <Box className="border-b-1">
@@ -133,7 +133,7 @@ function BackupsTable() {
function SectionContainer({ title }: any) { function SectionContainer({ title }: any) {
return ( return (
<div className="mt-6 w-full"> <div className="mt-6 w-full">
<Text className="font-medium text-lg">{title}</Text> <Text className="text-lg font-medium">{title}</Text>
<Text className="font-normal"> <Text className="font-normal">
The database backup includes database schema, database data and Hasura The database backup includes database schema, database data and Hasura
metadata. It does not include the actual files in Storage. metadata. It does not include the actual files in Storage.

View File

@@ -16,9 +16,7 @@ export default function DataBrowserDatabaseDetailsPage() {
description={ description={
<span> <span>
Database{' '} Database{' '}
<InlineCode className="bg-gray-200 bg-opacity-80 px-1.5 text-sm"> <InlineCode className="px-1.5 text-sm">{dataSourceSlug}</InlineCode>{' '}
{dataSourceSlug}
</InlineCode>{' '}
does not exist. does not exist.
</span> </span>
} }

View File

@@ -15,7 +15,7 @@ import type { ReactElement } from 'react';
export default function AppIndexPage() { export default function AppIndexPage() {
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const applicationState = useApplicationState(); const { state } = useApplicationState();
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
if (!isPlatform) { if (!isPlatform) {
@@ -26,7 +26,7 @@ export default function AppIndexPage() {
return <ApplicationMigrating />; return <ApplicationMigrating />;
} }
switch (applicationState) { switch (state) {
case ApplicationStatus.Empty: case ApplicationStatus.Empty:
return <ApplicationProvisioning />; return <ApplicationProvisioning />;
case ApplicationStatus.Provisioning: case ApplicationStatus.Provisioning:

View File

@@ -134,9 +134,5 @@ export default function LogsPage() {
} }
LogsPage.getLayout = function getLayout(page: ReactElement) { LogsPage.getLayout = function getLayout(page: ReactElement) {
return ( return <ProjectLayout>{page}</ProjectLayout>;
<ProjectLayout mainContainerProps={{ className: 'bg-gray-50' }}>
{page}
</ProjectLayout>
);
}; };

View File

@@ -8,7 +8,8 @@ import { useUI } from '@/context/UIContext';
import { import {
GetOneUserDocument, GetOneUserDocument,
useDeleteApplicationMutation, useDeleteApplicationMutation,
useUpdateAppMutation, usePauseApplicationMutation,
useUpdateApplicationMutation,
} from '@/generated/graphql'; } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication'; import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
@@ -37,9 +38,13 @@ export type ProjectNameValidationSchema = Yup.InferType<
export default function SettingsGeneralPage() { export default function SettingsGeneralPage() {
const { currentApplication } = useCurrentWorkspaceAndApplication(); const { currentApplication } = useCurrentWorkspaceAndApplication();
const { openDialog, closeDialog } = useDialog(); const { openDialog, openAlertDialog, closeDialog } = useDialog();
const [updateApp] = useUpdateAppMutation(); const [updateApp] = useUpdateApplicationMutation();
const client = useApolloClient(); const client = useApolloClient();
const [pauseApplication] = usePauseApplicationMutation({
variables: { appId: currentApplication?.id },
refetchQueries: [GetOneUserDocument],
});
const [deleteApplication] = useDeleteApplicationMutation({ const [deleteApplication] = useDeleteApplicationMutation({
variables: { appId: currentApplication?.id }, variables: { appId: currentApplication?.id },
refetchQueries: [GetOneUserDocument], refetchQueries: [GetOneUserDocument],
@@ -61,7 +66,7 @@ export default function SettingsGeneralPage() {
const { register, formState } = form; const { register, formState } = form;
const handleProjectNameChange = async (data: ProjectNameValidationSchema) => { async function handleProjectNameChange(data: ProjectNameValidationSchema) {
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `updating: true`. // In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `updating: true`.
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website // We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
// i.e. redirecting to 404 if there's no workspace/project with that slug. // i.e. redirecting to 404 if there's no workspace/project with that slug.
@@ -83,7 +88,7 @@ export default function SettingsGeneralPage() {
const updateAppMutation = updateApp({ const updateAppMutation = updateApp({
variables: { variables: {
id: currentApplication.id, appId: currentApplication.id,
app: { app: {
name: data.name, name: data.name,
slug: newProjectSlug, slug: newProjectSlug,
@@ -108,34 +113,50 @@ export default function SettingsGeneralPage() {
} }
try { try {
await client.refetchQueries({
include: ['getOneUser'],
});
form.reset(undefined, { keepValues: true, keepDirty: false }); form.reset(undefined, { keepValues: true, keepDirty: false });
await router.push( await router.push(
`/${currentWorkspace.slug}/${newProjectSlug}/settings/general`, `/${currentWorkspace.slug}/${newProjectSlug}/settings/general`,
); );
await client.refetchQueries({ include: [GetOneUserDocument] });
} catch (error) { } catch (error) {
await discordAnnounce( await discordAnnounce(
error.message || 'Error while trying to update application cache', error.message ||
'An error occurred while trying to update application cache.',
); );
} }
}; }
const handleDeleteApplication = async () => { async function handleDeleteApplication() {
await toast.promise( await toast.promise(
deleteApplication(), deleteApplication(),
{ {
loading: `Deleting ${currentApplication.name}...`, loading: `Deleting ${currentApplication.name}...`,
success: `${currentApplication.name} deleted`, success: `${currentApplication.name} has been deleted successfully.`,
error: getServerError( error: getServerError(
`Error while trying to ${currentApplication.name} project name`, `An error occurred while trying to delete the project "${currentApplication.name}". Please try again.`,
), ),
}, },
getToastStyleProps(), getToastStyleProps(),
); );
await router.push('/'); await router.push('/');
}; }
async function handlePauseApplication() {
await toast.promise(
pauseApplication(),
{
loading: `Pausing ${currentApplication.name}...`,
success: `${currentApplication.name} will be paused, but please note that it may take some time to complete the process.`,
error: getServerError(
`An error occurred while trying to pause the project "${currentApplication.name}". Please try again.`,
),
},
getToastStyleProps(),
);
await router.push('/');
}
return ( return (
<Container <Container
@@ -171,6 +192,32 @@ export default function SettingsGeneralPage() {
</Form> </Form>
</FormProvider> </FormProvider>
{currentApplication.plan.isFree && (
<SettingsContainer
title="Pause Project"
description="While your project is paused, it will not be accessible. You can wake it up anytime after."
submitButtonText="Pause"
slotProps={{
submitButton: {
type: 'button',
color: 'primary',
variant: 'contained',
disabled: maintenanceActive,
onClick: () => {
openAlertDialog({
title: 'Pause Project?',
payload:
'Are you sure you want to pause this project? It will not be accessible until you unpause it.',
props: {
onPrimaryAction: handlePauseApplication,
},
});
},
},
}}
/>
)}
<SettingsContainer <SettingsContainer
title="Delete Project" title="Delete Project"
description="The project will be permanently deleted, including its database, metadata, files, etc. This action is irreversible and can not be undone." description="The project will be permanently deleted, including its database, metadata, files, etc. This action is irreversible and can not be undone."

View File

@@ -2,6 +2,7 @@ import Container from '@/components/layout/Container';
import SettingsLayout from '@/components/settings/SettingsLayout'; import SettingsLayout from '@/components/settings/SettingsLayout';
import AnonymousSignInSettings from '@/components/settings/signInMethods/AnonymousSignInSettings'; import AnonymousSignInSettings from '@/components/settings/signInMethods/AnonymousSignInSettings';
import AppleProviderSettings from '@/components/settings/signInMethods/AppleProviderSettings'; import AppleProviderSettings from '@/components/settings/signInMethods/AppleProviderSettings';
import AzureADProviderSettings from '@/components/settings/signInMethods/AzureADProviderSettings';
import DiscordProviderSettings from '@/components/settings/signInMethods/DiscordProviderSettings'; import DiscordProviderSettings from '@/components/settings/signInMethods/DiscordProviderSettings';
import EmailAndPasswordSettings from '@/components/settings/signInMethods/EmailAndPasswordSettings'; import EmailAndPasswordSettings from '@/components/settings/signInMethods/EmailAndPasswordSettings';
import FacebookProviderSettings from '@/components/settings/signInMethods/FacebookProviderSettings'; import FacebookProviderSettings from '@/components/settings/signInMethods/FacebookProviderSettings';
@@ -56,6 +57,7 @@ export default function SettingsSignInMethodsPage() {
<SMSSettings /> <SMSSettings />
{!currentApplication.providersUpdated && <ProvidersUpdatedAlert />} {!currentApplication.providersUpdated && <ProvidersUpdatedAlert />}
<AppleProviderSettings /> <AppleProviderSettings />
<AzureADProviderSettings />
<DiscordProviderSettings /> <DiscordProviderSettings />
<FacebookProviderSettings /> <FacebookProviderSettings />
<GitHubProviderSettings /> <GitHubProviderSettings />

View File

@@ -11,23 +11,25 @@ import { Modal } from '@/ui/Modal';
import ActivityIndicator from '@/ui/v2/ActivityIndicator'; import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Box from '@/ui/v2/Box'; import Box from '@/ui/v2/Box';
import Button from '@/ui/v2/Button'; import Button from '@/ui/v2/Button';
import Checkbox from '@/ui/v2/Checkbox';
import IconButton from '@/ui/v2/IconButton'; import IconButton from '@/ui/v2/IconButton';
import CopyIcon from '@/ui/v2/icons/CopyIcon'; import CopyIcon from '@/ui/v2/icons/CopyIcon';
import Input from '@/ui/v2/Input'; import Input from '@/ui/v2/Input';
import InputAdornment from '@/ui/v2/InputAdornment'; import InputAdornment from '@/ui/v2/InputAdornment';
import Option from '@/ui/v2/Option'; import Option from '@/ui/v2/Option';
import Radio from '@/ui/v2/Radio';
import RadioGroup from '@/ui/v2/RadioGroup';
import Select from '@/ui/v2/Select'; import Select from '@/ui/v2/Select';
import type { TextProps } from '@/ui/v2/Text'; import type { TextProps } from '@/ui/v2/Text';
import Text from '@/ui/v2/Text'; import Text from '@/ui/v2/Text';
import Tooltip from '@/ui/v2/Tooltip';
import { MAX_FREE_PROJECTS } from '@/utils/CONSTANTS';
import { copy } from '@/utils/copy'; import { copy } from '@/utils/copy';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { getErrorMessage } from '@/utils/getErrorMessage'; import { getErrorMessage } from '@/utils/getErrorMessage';
import { getCurrentEnvironment, slugifyString } from '@/utils/helpers'; import { getCurrentEnvironment } from '@/utils/helpers';
import { nhost } from '@/utils/nhost';
import { planDescriptions } from '@/utils/planDescriptions'; import { planDescriptions } from '@/utils/planDescriptions';
import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword'; import generateRandomDatabasePassword from '@/utils/settings/generateRandomDatabasePassword';
import { resetDatabasePasswordValidationSchema } from '@/utils/settings/resetDatabasePasswordValidationSchema'; import { resetDatabasePasswordValidationSchema } from '@/utils/settings/resetDatabasePasswordValidationSchema';
import { getToastStyleProps } from '@/utils/settings/settingsConstants';
import { triggerToast } from '@/utils/toast'; import { triggerToast } from '@/utils/toast';
import type { import type {
PrefetchNewAppPlansFragment, PrefetchNewAppPlansFragment,
@@ -35,19 +37,25 @@ import type {
PrefetchNewAppWorkspaceFragment, PrefetchNewAppWorkspaceFragment,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import { import {
useGetFreeAndActiveProjectsQuery,
useInsertApplicationMutation, useInsertApplicationMutation,
usePrefetchNewAppQuery, usePrefetchNewAppQuery,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
import type { ApolloError } from '@apollo/client';
import { useUserData } from '@nhost/nextjs';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { cloneElement, isValidElement, useState } from 'react'; import { cloneElement, isValidElement, useState } from 'react';
import { toast } from 'react-hot-toast';
import slugify from 'slugify';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
type NewAppPageProps = { type NewAppPageProps = {
regions: PrefetchNewAppRegionsFragment[]; regions: PrefetchNewAppRegionsFragment[];
plans: PrefetchNewAppPlansFragment[]; plans: PrefetchNewAppPlansFragment[];
workspaces: PrefetchNewAppWorkspaceFragment[]; workspaces: PrefetchNewAppWorkspaceFragment[];
numberOfFreeAndLiveProjects: number;
preSelectedWorkspace: PrefetchNewAppWorkspaceFragment; preSelectedWorkspace: PrefetchNewAppWorkspaceFragment;
preSelectedRegion: PrefetchNewAppRegionsFragment; preSelectedRegion: PrefetchNewAppRegionsFragment;
}; };
@@ -56,6 +64,7 @@ export function NewProjectPageContent({
regions, regions,
plans, plans,
workspaces, workspaces,
numberOfFreeAndLiveProjects,
preSelectedWorkspace, preSelectedWorkspace,
preSelectedRegion, preSelectedRegion,
}: NewAppPageProps) { }: NewAppPageProps) {
@@ -86,15 +95,23 @@ export function NewProjectPageContent({
generateRandomDatabasePassword(), generateRandomDatabasePassword(),
); );
const [plan, setPlan] = useState(plans[0]); // find the first acceptable plan as default plan
const defaultSelectedPlan = plans.find((plan) => {
if (!plan.isFree) {
return true;
}
return numberOfFreeAndLiveProjects < MAX_FREE_PROJECTS;
});
const [plan, setPlan] = useState(defaultSelectedPlan);
// state // state
const { submitState, setSubmitState } = useSubmitState(); const { submitState, setSubmitState } = useSubmitState();
const [applicationError, setApplicationError] = useState<any>('');
const [showPaymentModal, setShowPaymentModal] = useState(false); const [showPaymentModal, setShowPaymentModal] = useState(false);
// graphql mutations // graphql mutations
const [insertApp] = useInsertApplicationMutation();
const [insertApp] = useInsertApplicationMutation({});
const { refetchUserData } = useLazyRefetchUserData(); const { refetchUserData } = useLazyRefetchUserData();
// options // options
@@ -119,8 +136,6 @@ export function NewProjectPageContent({
(availableWorkspace) => availableWorkspace.id === selectedWorkspace.id, (availableWorkspace) => availableWorkspace.id === selectedWorkspace.id,
); );
const user = nhost.auth.getUser();
const isK8SPostgresEnabledInCurrentEnvironment = features[ const isK8SPostgresEnabledInCurrentEnvironment = features[
'k8s-postgres' 'k8s-postgres'
].enabled.find((e) => e === getCurrentEnvironment()); ].enabled.find((e) => e === getCurrentEnvironment());
@@ -133,30 +148,24 @@ export function NewProjectPageContent({
setDatabasePassword(newRandomDatabasePassword); setDatabasePassword(newRandomDatabasePassword);
}; };
const handleSubmit = async () => { const handleSubmit = async (e) => {
e.preventDefault();
if (!plan.isFree && workspace.paymentMethods.length === 0) {
setShowPaymentModal(true);
return;
}
setSubmitState({ setSubmitState({
error: null, error: null,
loading: true, loading: true,
}); });
if (name.length < 1 || name.length > 32) { if (name.length < 1 || name.length > 32) {
setApplicationError(
`The project name must be between 1 and 32 characters`,
);
setSubmitState({ setSubmitState({
error: null, error: Error('The project name must be between 1 and 32 characters'),
loading: false, loading: false,
}); });
}
const slug = slugifyString(name);
if (slug.length < 1 || slug.length > 32) {
setSubmitState({
error: Error('The project slug must be between 1 and 32 characters.'),
loading: false,
});
return; return;
} }
@@ -173,14 +182,11 @@ export function NewProjectPageContent({
} }
} }
// NOTE: Maybe we'll reintroduce this way of creating the subdomain in the future const slug = slugify(name, { lower: true, strict: true });
// https://www.rfc-editor.org/rfc/rfc1034#section-3.1
// subdomain max length is 63 characters
// const subdomain = `${slug}-${workspaceSlug}`.substring(0, 63);
try { try {
if (isK8SPostgresEnabledInCurrentEnvironment) { await toast.promise(
await insertApp({ insertApp({
variables: { variables: {
app: { app: {
name, name,
@@ -188,37 +194,40 @@ export function NewProjectPageContent({
planId: plan.id, planId: plan.id,
workspaceId: selectedWorkspace.id, workspaceId: selectedWorkspace.id,
regionId: selectedRegion.id, regionId: selectedRegion.id,
postgresPassword: databasePassword, postgresPassword: isK8SPostgresEnabledInCurrentEnvironment
? databasePassword
: undefined,
}, },
}, },
}); }),
} else { {
await insertApp({ loading: 'Creating the project...',
variables: { success: 'The project has been created successfully.',
app: { error: (arg: ApolloError) => {
name, // we need to get the internal error message from the GraphQL error
slug, const { internal } = arg.graphQLErrors[0]?.extensions || {};
planId: plan.id, const { message } = (internal as Record<string, any>)?.error || {};
workspaceId: selectedWorkspace.id,
regionId: selectedRegion.id,
},
},
});
}
triggerToast(`New project ${name} created`); // we use the default Apollo error message if we can't find the
} catch (error) { // internal error message
discordAnnounce( return (
`Error creating project: ${error.message}. (${user.email})`, message ||
arg.message ||
'An error occurred while creating the project. Please try again.'
);
},
},
getToastStyleProps(),
); );
await refetchUserData();
await router.push(`/${selectedWorkspace.slug}/${slug}`);
} catch (error) {
setSubmitState({ setSubmitState({
error: Error(getErrorMessage(error, 'application')), error: null,
loading: false, loading: false,
}); });
} }
await refetchUserData();
router.push(`/${selectedWorkspace.slug}/${slug}`);
}; };
if (!selectedWorkspace) { if (!selectedWorkspace) {
@@ -243,383 +252,376 @@ export function NewProjectPageContent({
return ( return (
<Container> <Container>
<div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14"> <form onSubmit={handleSubmit}>
<Text variant="h2" component="h1"> <div className="mx-auto grid max-w-[760px] grid-flow-row gap-4 py-6 sm:py-14">
New Project <Text variant="h2" component="h1">
</Text> New Project
</Text>
<div className="grid grid-flow-row gap-4"> <div className="grid grid-flow-row gap-4">
<Input
id="name"
autoComplete="off"
label="Project Name"
variant="inline"
fullWidth
hideEmptyHelperText
placeholder="Project Name"
onChange={(event) => {
setSubmitState({
error: null,
loading: false,
});
setApplicationError('');
setName(event.target.value);
}}
value={name}
autoFocus
/>
<Select
id="workspace"
label="Workspace"
variant="inline"
hideEmptyHelperText
placeholder="Select Workspace"
slotProps={{
root: { className: 'grid grid-flow-col gap-1' },
}}
onChange={(_event, value) => {
const workspaceInList = workspaces.find(({ id }) => id === value);
setPlan(plans[0]);
setSelectedWorkspace({
id: workspaceInList.id,
name: workspaceInList.name,
disabled: false,
slug: workspaceInList.slug,
});
}}
value={selectedWorkspace.id}
renderValue={(option) => (
<span className="inline-grid grid-flow-col items-center gap-2">
{option?.label}
</span>
)}
>
{workspaceOptions.map((option) => (
<Option
value={option.id}
key={option.id}
className="grid grid-flow-col items-center gap-2"
>
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
<Image
src="/logos/new.svg"
alt="Nhost Logo"
width={24}
height={24}
/>
</span>
{option.name}
</Option>
))}
</Select>
{isK8SPostgresEnabledInCurrentEnvironment && (
<Input <Input
name="databasePassword" id="name"
id="databasePassword" autoComplete="off"
autoComplete="new-password" label="Project Name"
label="Database Password"
value={databasePassword}
variant="inline" variant="inline"
type="password" fullWidth
error={!!passwordError}
hideEmptyHelperText hideEmptyHelperText
endAdornment={ placeholder="Project Name"
<InputAdornment position="end" className="mr-2"> onChange={(event) => {
<IconButton
color="secondary"
onClick={() => {
copy(databasePassword, 'Postgres password');
}}
variant="borderless"
aria-label="Copy password"
>
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}
slotProps={{
// Note: this is supposed to fix a `validateDOMNesting` error
helperText: { component: 'div' },
}}
helperText={
<div className="grid max-w-xs grid-flow-row gap-2">
{passwordError && (
<Text
variant="subtitle2"
sx={{
color: (theme) =>
`${theme.palette.error.main} !important`,
}}
>
{passwordError}
</Text>
)}
<Box className="font-medium">
The root Postgres password for your database - it must be
strong and hard to guess.{' '}
<Button
type="button"
variant="borderless"
color="secondary"
onClick={handleGenerateRandomPassword}
className="px-1 py-0.5 text-xs underline underline-offset-2 hover:underline"
tabIndex={-1}
>
Generate a password
</Button>
</Box>
</div>
}
onChange={async (e) => {
e.preventDefault();
setSubmitState({ setSubmitState({
error: null, error: null,
loading: false, loading: false,
}); });
if (e.target.value.length === 0) { setName(event.target.value);
setDatabasePassword(e.target.value);
setPasswordError('Please enter a password');
return;
}
setDatabasePassword(e.target.value);
setPasswordError('');
try {
await resetDatabasePasswordValidationSchema.validate({
databasePassword: e.target.value,
});
setPasswordError('');
} catch (validationError) {
setPasswordError(validationError.message);
}
}} }}
fullWidth value={name}
autoFocus
/> />
)}
<Select <Select
id="region" id="workspace"
label="Region" label="Workspace"
variant="inline" variant="inline"
hideEmptyHelperText hideEmptyHelperText
placeholder="Select Region" placeholder="Select Workspace"
slotProps={{ slotProps={{
root: { className: 'grid grid-flow-col gap-1' }, root: { className: 'grid grid-flow-col gap-1' },
}} }}
onChange={(_event, value) => { onChange={(_event, value) => {
const regionInList = regions.find(({ id }) => id === value); const workspaceInList = workspaces.find(
setPlan(plans[0]); ({ id }) => id === value,
setSelectedRegion({ );
id: regionInList.id, setPlan(plans[0]);
name: regionInList.country.name, setSelectedWorkspace({
disabled: false, id: workspaceInList.id,
code: regionInList.country.code, name: workspaceInList.name,
}); disabled: false,
}} slug: workspaceInList.slug,
value={selectedRegion.id} });
renderValue={(option) => { }}
const [flag, , country] = (option?.label as any[]) || []; value={selectedWorkspace.id}
renderValue={(option) => (
return ( <span className="inline-grid grid-flow-col items-center gap-2">
<span className="inline-grid grid-flow-col grid-rows-none items-center gap-x-2"> {option?.label}
{flag}
{isValidElement<TextProps>(country)
? cloneElement(country, {
...country.props,
variant: 'body1',
})
: null}
</span>
);
}}
>
{regionOptions.map((option) => (
<Option
value={option.id}
key={option.id}
className={twMerge(
'relative grid grid-flow-col grid-rows-2 items-center justify-start gap-x-3',
option.disabled && 'pointer-events-none opacity-50',
)}
disabled={option.disabled}
>
<span className="row-span-2 flex">
<Image
src={`/assets/flags/${option.code}.svg`}
alt={`${option.country} country flag`}
width={16}
height={12}
/>
</span> </span>
)}
>
{workspaceOptions.map((option) => (
<Option
value={option.id}
key={option.id}
className="grid grid-flow-col items-center gap-2"
>
<span className="inline-block h-6 w-6 overflow-hidden rounded-md">
<Image
src="/logos/new.svg"
alt="Nhost Logo"
width={24}
height={24}
/>
</span>
<Text className="row-span-1 font-medium">{option.name}</Text> {option.name}
</Option>
))}
</Select>
<Text variant="subtitle2" className="row-span-1"> {isK8SPostgresEnabledInCurrentEnvironment && (
{option.country} <Input
</Text> name="databasePassword"
id="databasePassword"
autoComplete="new-password"
label="Database Password"
value={databasePassword}
variant="inline"
type="password"
error={!!passwordError}
hideEmptyHelperText
endAdornment={
<InputAdornment position="end" className="mr-2">
<IconButton
color="secondary"
onClick={() => {
copy(databasePassword, 'Postgres password');
}}
variant="borderless"
aria-label="Copy password"
>
<CopyIcon className="h-4 w-4" />
</IconButton>
</InputAdornment>
}
slotProps={{
// Note: this is supposed to fix a `validateDOMNesting` error
helperText: { component: 'div' },
}}
helperText={
<div className="grid max-w-xs grid-flow-row gap-2">
{passwordError && (
<Text
variant="subtitle2"
sx={{
color: (theme) =>
`${theme.palette.error.main} !important`,
}}
>
{passwordError}
</Text>
)}
{option.disabled && ( <Box className="font-medium">
<Text The root Postgres password for your database - it must be
variant="subtitle2" strong and hard to guess.{' '}
className="absolute top-1/2 right-4 -translate-y-1/2" <Button
> type="button"
Disabled variant="borderless"
</Text> color="secondary"
)} onClick={handleGenerateRandomPassword}
</Option> className="px-1 py-0.5 text-xs underline underline-offset-2 hover:underline"
))} tabIndex={-1}
</Select> >
Generate a password
</Button>
</Box>
</div>
}
onChange={async (e) => {
e.preventDefault();
setSubmitState({
error: null,
loading: false,
});
setDatabasePassword(e.target.value);
<div className="grid w-full grid-cols-8 gap-x-4 gap-y-2"> try {
<div className="col-span-8 sm:col-span-2"> await resetDatabasePasswordValidationSchema.validate({
<Text className="text-xs font-medium">Plan</Text> databasePassword: e.target.value,
<Text variant="subtitle2">You can change this later.</Text> });
</div> setPasswordError('');
} catch (validationError) {
setPasswordError(validationError.message);
}
}}
fullWidth
/>
)}
<div className="col-span-8 sm:col-span-6"> <Select
{plans.map((currentPlan) => { id="region"
const checked = plan.id === currentPlan.id; label="Region"
variant="inline"
hideEmptyHelperText
placeholder="Select Region"
slotProps={{
root: { className: 'grid grid-flow-col gap-1' },
}}
onChange={(_event, value) => {
const regionInList = regions.find(({ id }) => id === value);
setSelectedRegion({
id: regionInList.id,
name: regionInList.country.name,
disabled: false,
code: regionInList.country.code,
});
}}
value={selectedRegion.id}
renderValue={(option) => {
const [flag, , country] = (option?.label as any[]) || [];
return ( return (
<Box <span className="inline-grid grid-flow-col grid-rows-none items-center gap-x-2">
className="border-t py-4 last-of-type:border-b" {flag}
key={currentPlan.id}
>
<Checkbox
label={
<>
<span className="inline-block max-w-xs">
<span className="font-medium">
{currentPlan.name}:
</span>{' '}
{planDescriptions[currentPlan.name]}
</span>
{currentPlan.isFree ? ( {isValidElement<TextProps>(country)
<Text variant="h3" component="span"> ? cloneElement(country, {
Free ...country.props,
</Text> variant: 'body1',
) : ( })
<Text : null}
variant="h3" </span>
component="span"
className="inline-grid grid-flow-col items-center gap-1"
>
$ {currentPlan.price}{' '}
<Text variant="subtitle2" component="span">
/ mo
</Text>
</Text>
)}
</>
}
componentsProps={{
formControlLabel: {
className: 'flex',
componentsProps: {
typography: {
className:
'font-regular text-xs grid grid-flow-col justify-between items-center w-full',
},
},
},
}}
checked={checked}
key={currentPlan.id}
onChange={(event, inputChecked) => {
if (!inputChecked) {
event.preventDefault();
return;
}
setPlan(currentPlan);
}}
/>
</Box>
); );
})}
</div>
</div>
</div>
{submitState.error && (
<Alert severity="error" className="text-left">
<Text className="font-medium">Warning</Text>{' '}
<Text className="font-medium">
{submitState.error &&
getErrorMessage(submitState.error, 'application')}
</Text>
</Alert>
)}
<div className="flex justify-end">
{showPaymentModal && (
<Modal
showModal={showPaymentModal}
close={() => {
setShowPaymentModal(false);
}} }}
> >
<BillingPaymentMethodForm {regionOptions.map((option) => (
<Option
value={option.id}
key={option.id}
className={twMerge(
'relative grid grid-flow-col grid-rows-2 items-center justify-start gap-x-3',
option.disabled && 'pointer-events-none opacity-50',
)}
disabled={option.disabled}
>
<span className="row-span-2 flex">
<Image
src={`/assets/flags/${option.code}.svg`}
alt={`${option.country} country flag`}
width={16}
height={12}
/>
</span>
<Text className="row-span-1 font-medium">{option.name}</Text>
<Text variant="subtitle2" className="row-span-1">
{option.country}
</Text>
{option.disabled && (
<Text
variant="subtitle2"
className="absolute top-1/2 right-4 -translate-y-1/2"
>
Disabled
</Text>
)}
</Option>
))}
</Select>
<div className="grid w-full grid-cols-8 gap-x-4 gap-y-2">
<div className="col-span-8 sm:col-span-2">
<Text className="text-xs font-medium">Plan</Text>
<Text variant="subtitle2">You can change this later.</Text>
</div>
<RadioGroup
value={plan.id}
onChange={(_event, value) => {
setPlan(plans.find((p) => p.id === value));
}}
className="col-span-8 space-y-2 sm:col-span-6"
>
{plans.map((currentPlan) => {
const disabledPlan =
currentPlan.isFree &&
numberOfFreeAndLiveProjects >= MAX_FREE_PROJECTS;
return (
<Tooltip
visible={disabledPlan}
title="Only one free project can be active at any given time. Please pause your active free project before creating a new one."
key={currentPlan.id}
slotProps={{
tooltip: { className: '!max-w-xs w-full text-center' },
}}
>
<Box className="w-full rounded-md border">
<Radio
slotProps={{
formControl: {
className: 'p-3 w-full',
slotProps: {
typography: { className: 'w-full' },
},
},
}}
value={currentPlan.id}
disabled={disabledPlan}
label={
<div className="flex w-full items-center justify-between ">
<div className="inline-block max-w-xs">
<Text className="font-medium text-[inherit]">
{currentPlan.name}
</Text>
<Text className="text-xs text-[inherit]">
{planDescriptions[currentPlan.name]}
</Text>
</div>
{currentPlan.isFree ? (
<Text
variant="h3"
component="span"
className="text-[inherit]"
>
Free
</Text>
) : (
<Text variant="h3" component="span">
${currentPlan.price}/mo
</Text>
)}
</div>
}
/>
</Box>
</Tooltip>
);
})}
</RadioGroup>
</div>
</div>
{submitState.error && (
<Alert severity="error" className="text-left">
<Text className="font-medium">Error</Text>{' '}
<Text className="font-medium">
{submitState.error &&
getErrorMessage(submitState.error, 'application')}{' '}
</Text>
</Alert>
)}
<div className="flex justify-end">
{showPaymentModal && (
<Modal
showModal={showPaymentModal}
close={() => { close={() => {
setShowPaymentModal(false); setShowPaymentModal(false);
}} }}
onPaymentMethodAdded={handleSubmit} >
workspaceId={workspace.id} <BillingPaymentMethodForm
/> close={() => {
</Modal> setShowPaymentModal(false);
)} }}
onPaymentMethodAdded={handleSubmit}
workspaceId={workspace.id}
/>
</Modal>
)}
<Button <Button
onClick={() => { type="submit"
if (!plan.isFree && workspace.paymentMethods.length === 0) { loading={submitState.loading}
setShowPaymentModal(true); disabled={!!passwordError || maintenanceActive}
id="create-app"
return; >
} Create Project
</Button>
handleSubmit(); </div>
}}
type="submit"
loading={submitState.loading}
disabled={
!!applicationError ||
!!submitState.error ||
!!passwordError ||
maintenanceActive
}
id="create-app"
>
Create Project
</Button>
</div> </div>
</div> </form>
</Container> </Container>
); );
} }
export default function NewProjectPage() { export default function NewProjectPage() {
const { data, loading, error } = usePrefetchNewAppQuery();
const router = useRouter(); const router = useRouter();
const user = useUserData();
if (error) { const { data, loading, error } = usePrefetchNewAppQuery();
throw error;
const {
data: freeAndActiveProjectsData,
loading: freeAndActiveProjectsLoading,
error: freeAndActiveProjectsError,
} = useGetFreeAndActiveProjectsQuery({
variables: { userId: user?.id },
fetchPolicy: 'cache-and-network',
});
if (error || freeAndActiveProjectsError) {
throw error || freeAndActiveProjectsError;
} }
if (loading) { if (loading || freeAndActiveProjectsLoading) {
return ( return (
<ActivityIndicator delay={500} label="Loading plans and regions..." /> <ActivityIndicator delay={500} label="Loading plans and regions..." />
); );
} }
const { workspace } = router.query; const { workspace } = router.query;
const { regions, plans, workspaces } = data; const { regions, plans, workspaces } = data;
// get pre-selected workspace // get pre-selected workspace
@@ -628,13 +630,16 @@ export default function NewProjectPage() {
? workspaces.find((w) => w.slug === workspace) ? workspaces.find((w) => w.slug === workspace)
: workspaces[0]; : workspaces[0];
const preSelectedRegion = regions.filter((region) => region.active)[0]; const preSelectedRegion = regions.find((region) => region.active);
return ( return (
<NewProjectPageContent <NewProjectPageContent
regions={regions} regions={regions}
plans={plans} plans={plans}
workspaces={workspaces} workspaces={workspaces}
numberOfFreeAndLiveProjects={
freeAndActiveProjectsData?.freeAndActiveProjects.length
}
preSelectedWorkspace={preSelectedWorkspace} preSelectedWorkspace={preSelectedWorkspace}
preSelectedRegion={preSelectedRegion} preSelectedRegion={preSelectedRegion}
/> />

View File

@@ -6,6 +6,7 @@ import type {
PermissionVariableFragment, PermissionVariableFragment,
ProjectFragment, ProjectFragment,
SecretFragment, SecretFragment,
WorkspaceFragment,
} from '@/utils/__generated__/graphql'; } from '@/utils/__generated__/graphql';
/** /**
@@ -62,6 +63,7 @@ export type FeatureFlag = {
value: string; value: string;
}; };
export type Workspace = WorkspaceFragment;
export type Project = ProjectFragment; export type Project = ProjectFragment;
export interface PermissionVariable extends PermissionVariableFragment { export interface PermissionVariable extends PermissionVariableFragment {

View File

@@ -22,3 +22,8 @@ export const READ_ONLY_SCHEMAS = ['auth', 'storage'];
* Key used to store the color preference in local storage. * Key used to store the color preference in local storage.
*/ */
export const COLOR_PREFERENCE_STORAGE_KEY = 'nhost-color-preference'; export const COLOR_PREFERENCE_STORAGE_KEY = 'nhost-color-preference';
/**
* Maximum number of free projects a user is allowed to have.
*/
export const MAX_FREE_PROJECTS = 1;

View File

@@ -25,6 +25,7 @@ export type Scalars = {
bpchar: any; bpchar: any;
bytea: any; bytea: any;
citext: any; citext: any;
float64: any;
jsonb: any; jsonb: any;
smallint: any; smallint: any;
timestamp: any; timestamp: any;
@@ -865,6 +866,38 @@ export type ConfigBooleanComparisonExp = {
_nin?: InputMaybe<Array<Scalars['Boolean']>>; _nin?: InputMaybe<Array<Scalars['Boolean']>>;
}; };
export type ConfigClaimMap = {
__typename?: 'ConfigClaimMap';
claim: Scalars['String'];
default?: Maybe<Scalars['String']>;
path?: Maybe<Scalars['String']>;
value?: Maybe<Scalars['String']>;
};
export type ConfigClaimMapComparisonExp = {
_and?: InputMaybe<Array<ConfigClaimMapComparisonExp>>;
_not?: InputMaybe<ConfigClaimMapComparisonExp>;
_or?: InputMaybe<Array<ConfigClaimMapComparisonExp>>;
claim?: InputMaybe<ConfigStringComparisonExp>;
default?: InputMaybe<ConfigStringComparisonExp>;
path?: InputMaybe<ConfigStringComparisonExp>;
value?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigClaimMapInsertInput = {
claim: Scalars['String'];
default?: InputMaybe<Scalars['String']>;
path?: InputMaybe<Scalars['String']>;
value?: InputMaybe<Scalars['String']>;
};
export type ConfigClaimMapUpdateInput = {
claim?: InputMaybe<Scalars['String']>;
default?: InputMaybe<Scalars['String']>;
path?: InputMaybe<Scalars['String']>;
value?: InputMaybe<Scalars['String']>;
};
export type ConfigConfig = { export type ConfigConfig = {
__typename?: 'ConfigConfig'; __typename?: 'ConfigConfig';
auth?: Maybe<ConfigAuth>; auth?: Maybe<ConfigAuth>;
@@ -1003,7 +1036,9 @@ export type ConfigGlobalUpdateInput = {
export type ConfigHasura = { export type ConfigHasura = {
__typename?: 'ConfigHasura'; __typename?: 'ConfigHasura';
adminSecret: Scalars['String']; adminSecret: Scalars['String'];
events?: Maybe<ConfigHasuraEvents>;
jwtSecrets?: Maybe<Array<ConfigJwtSecret>>; jwtSecrets?: Maybe<Array<ConfigJwtSecret>>;
logs?: Maybe<ConfigHasuraLogs>;
resources?: Maybe<ConfigResources>; resources?: Maybe<ConfigResources>;
settings?: Maybe<ConfigHasuraSettings>; settings?: Maybe<ConfigHasuraSettings>;
version?: Maybe<Scalars['String']>; version?: Maybe<Scalars['String']>;
@@ -1015,22 +1050,66 @@ export type ConfigHasuraComparisonExp = {
_not?: InputMaybe<ConfigHasuraComparisonExp>; _not?: InputMaybe<ConfigHasuraComparisonExp>;
_or?: InputMaybe<Array<ConfigHasuraComparisonExp>>; _or?: InputMaybe<Array<ConfigHasuraComparisonExp>>;
adminSecret?: InputMaybe<ConfigStringComparisonExp>; adminSecret?: InputMaybe<ConfigStringComparisonExp>;
events?: InputMaybe<ConfigHasuraEventsComparisonExp>;
jwtSecrets?: InputMaybe<ConfigJwtSecretComparisonExp>; jwtSecrets?: InputMaybe<ConfigJwtSecretComparisonExp>;
logs?: InputMaybe<ConfigHasuraLogsComparisonExp>;
resources?: InputMaybe<ConfigResourcesComparisonExp>; resources?: InputMaybe<ConfigResourcesComparisonExp>;
settings?: InputMaybe<ConfigHasuraSettingsComparisonExp>; settings?: InputMaybe<ConfigHasuraSettingsComparisonExp>;
version?: InputMaybe<ConfigStringComparisonExp>; version?: InputMaybe<ConfigStringComparisonExp>;
webhookSecret?: InputMaybe<ConfigStringComparisonExp>; webhookSecret?: InputMaybe<ConfigStringComparisonExp>;
}; };
export type ConfigHasuraEvents = {
__typename?: 'ConfigHasuraEvents';
httpPoolSize?: Maybe<Scalars['ConfigUint32']>;
};
export type ConfigHasuraEventsComparisonExp = {
_and?: InputMaybe<Array<ConfigHasuraEventsComparisonExp>>;
_not?: InputMaybe<ConfigHasuraEventsComparisonExp>;
_or?: InputMaybe<Array<ConfigHasuraEventsComparisonExp>>;
httpPoolSize?: InputMaybe<ConfigUint32ComparisonExp>;
};
export type ConfigHasuraEventsInsertInput = {
httpPoolSize?: InputMaybe<Scalars['ConfigUint32']>;
};
export type ConfigHasuraEventsUpdateInput = {
httpPoolSize?: InputMaybe<Scalars['ConfigUint32']>;
};
export type ConfigHasuraInsertInput = { export type ConfigHasuraInsertInput = {
adminSecret: Scalars['String']; adminSecret: Scalars['String'];
events?: InputMaybe<ConfigHasuraEventsInsertInput>;
jwtSecrets?: InputMaybe<Array<ConfigJwtSecretInsertInput>>; jwtSecrets?: InputMaybe<Array<ConfigJwtSecretInsertInput>>;
logs?: InputMaybe<ConfigHasuraLogsInsertInput>;
resources?: InputMaybe<ConfigResourcesInsertInput>; resources?: InputMaybe<ConfigResourcesInsertInput>;
settings?: InputMaybe<ConfigHasuraSettingsInsertInput>; settings?: InputMaybe<ConfigHasuraSettingsInsertInput>;
version?: InputMaybe<Scalars['String']>; version?: InputMaybe<Scalars['String']>;
webhookSecret: Scalars['String']; webhookSecret: Scalars['String'];
}; };
export type ConfigHasuraLogs = {
__typename?: 'ConfigHasuraLogs';
level?: Maybe<Scalars['String']>;
};
export type ConfigHasuraLogsComparisonExp = {
_and?: InputMaybe<Array<ConfigHasuraLogsComparisonExp>>;
_not?: InputMaybe<ConfigHasuraLogsComparisonExp>;
_or?: InputMaybe<Array<ConfigHasuraLogsComparisonExp>>;
level?: InputMaybe<ConfigStringComparisonExp>;
};
export type ConfigHasuraLogsInsertInput = {
level?: InputMaybe<Scalars['String']>;
};
export type ConfigHasuraLogsUpdateInput = {
level?: InputMaybe<Scalars['String']>;
};
export type ConfigHasuraSettings = { export type ConfigHasuraSettings = {
__typename?: 'ConfigHasuraSettings'; __typename?: 'ConfigHasuraSettings';
enableRemoteSchemaPermissions?: Maybe<Scalars['Boolean']>; enableRemoteSchemaPermissions?: Maybe<Scalars['Boolean']>;
@@ -1053,7 +1132,9 @@ export type ConfigHasuraSettingsUpdateInput = {
export type ConfigHasuraUpdateInput = { export type ConfigHasuraUpdateInput = {
adminSecret?: InputMaybe<Scalars['String']>; adminSecret?: InputMaybe<Scalars['String']>;
events?: InputMaybe<ConfigHasuraEventsUpdateInput>;
jwtSecrets?: InputMaybe<Array<ConfigJwtSecretUpdateInput>>; jwtSecrets?: InputMaybe<Array<ConfigJwtSecretUpdateInput>>;
logs?: InputMaybe<ConfigHasuraLogsUpdateInput>;
resources?: InputMaybe<ConfigResourcesUpdateInput>; resources?: InputMaybe<ConfigResourcesUpdateInput>;
settings?: InputMaybe<ConfigHasuraSettingsUpdateInput>; settings?: InputMaybe<ConfigHasuraSettingsUpdateInput>;
version?: InputMaybe<Scalars['String']>; version?: InputMaybe<Scalars['String']>;
@@ -1079,6 +1160,7 @@ export type ConfigJwtSecret = {
allowed_skew?: Maybe<Scalars['ConfigUint32']>; allowed_skew?: Maybe<Scalars['ConfigUint32']>;
audience?: Maybe<Scalars['String']>; audience?: Maybe<Scalars['String']>;
claims_format?: Maybe<Scalars['String']>; claims_format?: Maybe<Scalars['String']>;
claims_map?: Maybe<Array<ConfigClaimMap>>;
claims_namespace?: Maybe<Scalars['String']>; claims_namespace?: Maybe<Scalars['String']>;
claims_namespace_path?: Maybe<Scalars['String']>; claims_namespace_path?: Maybe<Scalars['String']>;
header?: Maybe<Scalars['String']>; header?: Maybe<Scalars['String']>;
@@ -1095,6 +1177,7 @@ export type ConfigJwtSecretComparisonExp = {
allowed_skew?: InputMaybe<ConfigUint32ComparisonExp>; allowed_skew?: InputMaybe<ConfigUint32ComparisonExp>;
audience?: InputMaybe<ConfigStringComparisonExp>; audience?: InputMaybe<ConfigStringComparisonExp>;
claims_format?: InputMaybe<ConfigStringComparisonExp>; claims_format?: InputMaybe<ConfigStringComparisonExp>;
claims_map?: InputMaybe<ConfigClaimMapComparisonExp>;
claims_namespace?: InputMaybe<ConfigStringComparisonExp>; claims_namespace?: InputMaybe<ConfigStringComparisonExp>;
claims_namespace_path?: InputMaybe<ConfigStringComparisonExp>; claims_namespace_path?: InputMaybe<ConfigStringComparisonExp>;
header?: InputMaybe<ConfigStringComparisonExp>; header?: InputMaybe<ConfigStringComparisonExp>;
@@ -1108,6 +1191,7 @@ export type ConfigJwtSecretInsertInput = {
allowed_skew?: InputMaybe<Scalars['ConfigUint32']>; allowed_skew?: InputMaybe<Scalars['ConfigUint32']>;
audience?: InputMaybe<Scalars['String']>; audience?: InputMaybe<Scalars['String']>;
claims_format?: InputMaybe<Scalars['String']>; claims_format?: InputMaybe<Scalars['String']>;
claims_map?: InputMaybe<Array<ConfigClaimMapInsertInput>>;
claims_namespace?: InputMaybe<Scalars['String']>; claims_namespace?: InputMaybe<Scalars['String']>;
claims_namespace_path?: InputMaybe<Scalars['String']>; claims_namespace_path?: InputMaybe<Scalars['String']>;
header?: InputMaybe<Scalars['String']>; header?: InputMaybe<Scalars['String']>;
@@ -1121,6 +1205,7 @@ export type ConfigJwtSecretUpdateInput = {
allowed_skew?: InputMaybe<Scalars['ConfigUint32']>; allowed_skew?: InputMaybe<Scalars['ConfigUint32']>;
audience?: InputMaybe<Scalars['String']>; audience?: InputMaybe<Scalars['String']>;
claims_format?: InputMaybe<Scalars['String']>; claims_format?: InputMaybe<Scalars['String']>;
claims_map?: InputMaybe<Array<ConfigClaimMapUpdateInput>>;
claims_namespace?: InputMaybe<Scalars['String']>; claims_namespace?: InputMaybe<Scalars['String']>;
claims_namespace_path?: InputMaybe<Scalars['String']>; claims_namespace_path?: InputMaybe<Scalars['String']>;
header?: InputMaybe<Scalars['String']>; header?: InputMaybe<Scalars['String']>;
@@ -1603,6 +1688,17 @@ export type Log = {
timestamp: Scalars['Timestamp']; timestamp: Scalars['Timestamp'];
}; };
export type Metrics = {
__typename?: 'Metrics';
value: Scalars['float64'];
};
export type StatsLiveApps = {
__typename?: 'StatsLiveApps';
appID: Array<Scalars['uuid']>;
count: Scalars['Int'];
};
/** Boolean expression to compare columns of type "String". All fields are combined with logical 'AND'. */ /** Boolean expression to compare columns of type "String". All fields are combined with logical 'AND'. */
export type String_Comparison_Exp = { export type String_Comparison_Exp = {
_eq?: InputMaybe<Scalars['String']>; _eq?: InputMaybe<Scalars['String']>;
@@ -4842,6 +4938,7 @@ export type Backups = {
appId: Scalars['uuid']; appId: Scalars['uuid'];
completedAt?: Maybe<Scalars['timestamptz']>; completedAt?: Maybe<Scalars['timestamptz']>;
createdAt: Scalars['timestamptz']; createdAt: Scalars['timestamptz'];
expiresAt?: Maybe<Scalars['timestamptz']>;
id: Scalars['uuid']; id: Scalars['uuid'];
size: Scalars['bigint']; size: Scalars['bigint'];
}; };
@@ -4929,6 +5026,7 @@ export type Backups_Bool_Exp = {
appId?: InputMaybe<Uuid_Comparison_Exp>; appId?: InputMaybe<Uuid_Comparison_Exp>;
completedAt?: InputMaybe<Timestamptz_Comparison_Exp>; completedAt?: InputMaybe<Timestamptz_Comparison_Exp>;
createdAt?: InputMaybe<Timestamptz_Comparison_Exp>; createdAt?: InputMaybe<Timestamptz_Comparison_Exp>;
expiresAt?: InputMaybe<Timestamptz_Comparison_Exp>;
id?: InputMaybe<Uuid_Comparison_Exp>; id?: InputMaybe<Uuid_Comparison_Exp>;
size?: InputMaybe<Bigint_Comparison_Exp>; size?: InputMaybe<Bigint_Comparison_Exp>;
}; };
@@ -4950,6 +5048,7 @@ export type Backups_Insert_Input = {
appId?: InputMaybe<Scalars['uuid']>; appId?: InputMaybe<Scalars['uuid']>;
completedAt?: InputMaybe<Scalars['timestamptz']>; completedAt?: InputMaybe<Scalars['timestamptz']>;
createdAt?: InputMaybe<Scalars['timestamptz']>; createdAt?: InputMaybe<Scalars['timestamptz']>;
expiresAt?: InputMaybe<Scalars['timestamptz']>;
id?: InputMaybe<Scalars['uuid']>; id?: InputMaybe<Scalars['uuid']>;
size?: InputMaybe<Scalars['bigint']>; size?: InputMaybe<Scalars['bigint']>;
}; };
@@ -4960,6 +5059,7 @@ export type Backups_Max_Fields = {
appId?: Maybe<Scalars['uuid']>; appId?: Maybe<Scalars['uuid']>;
completedAt?: Maybe<Scalars['timestamptz']>; completedAt?: Maybe<Scalars['timestamptz']>;
createdAt?: Maybe<Scalars['timestamptz']>; createdAt?: Maybe<Scalars['timestamptz']>;
expiresAt?: Maybe<Scalars['timestamptz']>;
id?: Maybe<Scalars['uuid']>; id?: Maybe<Scalars['uuid']>;
size?: Maybe<Scalars['bigint']>; size?: Maybe<Scalars['bigint']>;
}; };
@@ -4969,6 +5069,7 @@ export type Backups_Max_Order_By = {
appId?: InputMaybe<Order_By>; appId?: InputMaybe<Order_By>;
completedAt?: InputMaybe<Order_By>; completedAt?: InputMaybe<Order_By>;
createdAt?: InputMaybe<Order_By>; createdAt?: InputMaybe<Order_By>;
expiresAt?: InputMaybe<Order_By>;
id?: InputMaybe<Order_By>; id?: InputMaybe<Order_By>;
size?: InputMaybe<Order_By>; size?: InputMaybe<Order_By>;
}; };
@@ -4979,6 +5080,7 @@ export type Backups_Min_Fields = {
appId?: Maybe<Scalars['uuid']>; appId?: Maybe<Scalars['uuid']>;
completedAt?: Maybe<Scalars['timestamptz']>; completedAt?: Maybe<Scalars['timestamptz']>;
createdAt?: Maybe<Scalars['timestamptz']>; createdAt?: Maybe<Scalars['timestamptz']>;
expiresAt?: Maybe<Scalars['timestamptz']>;
id?: Maybe<Scalars['uuid']>; id?: Maybe<Scalars['uuid']>;
size?: Maybe<Scalars['bigint']>; size?: Maybe<Scalars['bigint']>;
}; };
@@ -4988,6 +5090,7 @@ export type Backups_Min_Order_By = {
appId?: InputMaybe<Order_By>; appId?: InputMaybe<Order_By>;
completedAt?: InputMaybe<Order_By>; completedAt?: InputMaybe<Order_By>;
createdAt?: InputMaybe<Order_By>; createdAt?: InputMaybe<Order_By>;
expiresAt?: InputMaybe<Order_By>;
id?: InputMaybe<Order_By>; id?: InputMaybe<Order_By>;
size?: InputMaybe<Order_By>; size?: InputMaybe<Order_By>;
}; };
@@ -5014,6 +5117,7 @@ export type Backups_Order_By = {
appId?: InputMaybe<Order_By>; appId?: InputMaybe<Order_By>;
completedAt?: InputMaybe<Order_By>; completedAt?: InputMaybe<Order_By>;
createdAt?: InputMaybe<Order_By>; createdAt?: InputMaybe<Order_By>;
expiresAt?: InputMaybe<Order_By>;
id?: InputMaybe<Order_By>; id?: InputMaybe<Order_By>;
size?: InputMaybe<Order_By>; size?: InputMaybe<Order_By>;
}; };
@@ -5032,6 +5136,8 @@ export enum Backups_Select_Column {
/** column name */ /** column name */
CreatedAt = 'createdAt', CreatedAt = 'createdAt',
/** column name */ /** column name */
ExpiresAt = 'expiresAt',
/** column name */
Id = 'id', Id = 'id',
/** column name */ /** column name */
Size = 'size' Size = 'size'
@@ -5042,6 +5148,7 @@ export type Backups_Set_Input = {
appId?: InputMaybe<Scalars['uuid']>; appId?: InputMaybe<Scalars['uuid']>;
completedAt?: InputMaybe<Scalars['timestamptz']>; completedAt?: InputMaybe<Scalars['timestamptz']>;
createdAt?: InputMaybe<Scalars['timestamptz']>; createdAt?: InputMaybe<Scalars['timestamptz']>;
expiresAt?: InputMaybe<Scalars['timestamptz']>;
id?: InputMaybe<Scalars['uuid']>; id?: InputMaybe<Scalars['uuid']>;
size?: InputMaybe<Scalars['bigint']>; size?: InputMaybe<Scalars['bigint']>;
}; };
@@ -5092,6 +5199,7 @@ export type Backups_Stream_Cursor_Value_Input = {
appId?: InputMaybe<Scalars['uuid']>; appId?: InputMaybe<Scalars['uuid']>;
completedAt?: InputMaybe<Scalars['timestamptz']>; completedAt?: InputMaybe<Scalars['timestamptz']>;
createdAt?: InputMaybe<Scalars['timestamptz']>; createdAt?: InputMaybe<Scalars['timestamptz']>;
expiresAt?: InputMaybe<Scalars['timestamptz']>;
id?: InputMaybe<Scalars['uuid']>; id?: InputMaybe<Scalars['uuid']>;
size?: InputMaybe<Scalars['bigint']>; size?: InputMaybe<Scalars['bigint']>;
}; };
@@ -5116,6 +5224,8 @@ export enum Backups_Update_Column {
/** column name */ /** column name */
CreatedAt = 'createdAt', CreatedAt = 'createdAt',
/** column name */ /** column name */
ExpiresAt = 'expiresAt',
/** column name */
Id = 'id', Id = 'id',
/** column name */ /** column name */
Size = 'size' Size = 'size'
@@ -9114,6 +9224,8 @@ export type Mutation_Root = {
/** insert a single row into the table: "regions" */ /** insert a single row into the table: "regions" */
insert_regions_one?: Maybe<Regions>; insert_regions_one?: Maybe<Regions>;
migrateRDSToPostgres: Scalars['Boolean']; migrateRDSToPostgres: Scalars['Boolean'];
pauseInactiveApps: Array<Scalars['String']>;
replaceConfig: ConfigConfig;
resetPostgresPassword: Scalars['Boolean']; resetPostgresPassword: Scalars['Boolean'];
restoreApplicationDatabase: Scalars['Boolean']; restoreApplicationDatabase: Scalars['Boolean'];
/** update single row of the table: "apps" */ /** update single row of the table: "apps" */
@@ -9302,9 +9414,16 @@ export type Mutation_Root = {
}; };
/** mutation root */
export type Mutation_RootBackupAllApplicationsDatabaseArgs = {
expireInDays?: InputMaybe<Scalars['Int']>;
};
/** mutation root */ /** mutation root */
export type Mutation_RootBackupApplicationDatabaseArgs = { export type Mutation_RootBackupApplicationDatabaseArgs = {
appID: Scalars['String']; appID: Scalars['String'];
expireInDays?: InputMaybe<Scalars['Int']>;
}; };
@@ -10124,6 +10243,13 @@ export type Mutation_RootMigrateRdsToPostgresArgs = {
}; };
/** mutation root */
export type Mutation_RootReplaceConfigArgs = {
appID: Scalars['uuid'];
config: ConfigConfigInsertInput;
};
/** mutation root */ /** mutation root */
export type Mutation_RootResetPostgresPasswordArgs = { export type Mutation_RootResetPostgresPasswordArgs = {
appID: Scalars['String']; appID: Scalars['String'];
@@ -11302,6 +11428,7 @@ export type Plans = {
featureBackupEnabled: Scalars['Boolean']; featureBackupEnabled: Scalars['Boolean'];
featureCustomDomainsEnabled: Scalars['Boolean']; featureCustomDomainsEnabled: Scalars['Boolean'];
featureCustomEmailTemplatesEnabled: Scalars['Boolean']; featureCustomEmailTemplatesEnabled: Scalars['Boolean'];
featureCustomResources: Scalars['Boolean'];
/** Weather or not to deploy email templates for git deployments */ /** Weather or not to deploy email templates for git deployments */
featureDeployEmailTemplates: Scalars['Boolean']; featureDeployEmailTemplates: Scalars['Boolean'];
/** Function execution timeout in seconds */ /** Function execution timeout in seconds */
@@ -11395,6 +11522,7 @@ export type Plans_Bool_Exp = {
featureBackupEnabled?: InputMaybe<Boolean_Comparison_Exp>; featureBackupEnabled?: InputMaybe<Boolean_Comparison_Exp>;
featureCustomDomainsEnabled?: InputMaybe<Boolean_Comparison_Exp>; featureCustomDomainsEnabled?: InputMaybe<Boolean_Comparison_Exp>;
featureCustomEmailTemplatesEnabled?: InputMaybe<Boolean_Comparison_Exp>; featureCustomEmailTemplatesEnabled?: InputMaybe<Boolean_Comparison_Exp>;
featureCustomResources?: InputMaybe<Boolean_Comparison_Exp>;
featureDeployEmailTemplates?: InputMaybe<Boolean_Comparison_Exp>; featureDeployEmailTemplates?: InputMaybe<Boolean_Comparison_Exp>;
featureFunctionExecutionTimeout?: InputMaybe<Int_Comparison_Exp>; featureFunctionExecutionTimeout?: InputMaybe<Int_Comparison_Exp>;
featureMaxDbSize?: InputMaybe<Int_Comparison_Exp>; featureMaxDbSize?: InputMaybe<Int_Comparison_Exp>;
@@ -11436,6 +11564,7 @@ export type Plans_Insert_Input = {
featureBackupEnabled?: InputMaybe<Scalars['Boolean']>; featureBackupEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomDomainsEnabled?: InputMaybe<Scalars['Boolean']>; featureCustomDomainsEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomEmailTemplatesEnabled?: InputMaybe<Scalars['Boolean']>; featureCustomEmailTemplatesEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomResources?: InputMaybe<Scalars['Boolean']>;
/** Weather or not to deploy email templates for git deployments */ /** Weather or not to deploy email templates for git deployments */
featureDeployEmailTemplates?: InputMaybe<Scalars['Boolean']>; featureDeployEmailTemplates?: InputMaybe<Scalars['Boolean']>;
/** Function execution timeout in seconds */ /** Function execution timeout in seconds */
@@ -11521,6 +11650,7 @@ export type Plans_Order_By = {
featureBackupEnabled?: InputMaybe<Order_By>; featureBackupEnabled?: InputMaybe<Order_By>;
featureCustomDomainsEnabled?: InputMaybe<Order_By>; featureCustomDomainsEnabled?: InputMaybe<Order_By>;
featureCustomEmailTemplatesEnabled?: InputMaybe<Order_By>; featureCustomEmailTemplatesEnabled?: InputMaybe<Order_By>;
featureCustomResources?: InputMaybe<Order_By>;
featureDeployEmailTemplates?: InputMaybe<Order_By>; featureDeployEmailTemplates?: InputMaybe<Order_By>;
featureFunctionExecutionTimeout?: InputMaybe<Order_By>; featureFunctionExecutionTimeout?: InputMaybe<Order_By>;
featureMaxDbSize?: InputMaybe<Order_By>; featureMaxDbSize?: InputMaybe<Order_By>;
@@ -11553,6 +11683,8 @@ export enum Plans_Select_Column {
/** column name */ /** column name */
FeatureCustomEmailTemplatesEnabled = 'featureCustomEmailTemplatesEnabled', FeatureCustomEmailTemplatesEnabled = 'featureCustomEmailTemplatesEnabled',
/** column name */ /** column name */
FeatureCustomResources = 'featureCustomResources',
/** column name */
FeatureDeployEmailTemplates = 'featureDeployEmailTemplates', FeatureDeployEmailTemplates = 'featureDeployEmailTemplates',
/** column name */ /** column name */
FeatureFunctionExecutionTimeout = 'featureFunctionExecutionTimeout', FeatureFunctionExecutionTimeout = 'featureFunctionExecutionTimeout',
@@ -11588,6 +11720,7 @@ export type Plans_Set_Input = {
featureBackupEnabled?: InputMaybe<Scalars['Boolean']>; featureBackupEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomDomainsEnabled?: InputMaybe<Scalars['Boolean']>; featureCustomDomainsEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomEmailTemplatesEnabled?: InputMaybe<Scalars['Boolean']>; featureCustomEmailTemplatesEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomResources?: InputMaybe<Scalars['Boolean']>;
/** Weather or not to deploy email templates for git deployments */ /** Weather or not to deploy email templates for git deployments */
featureDeployEmailTemplates?: InputMaybe<Scalars['Boolean']>; featureDeployEmailTemplates?: InputMaybe<Scalars['Boolean']>;
/** Function execution timeout in seconds */ /** Function execution timeout in seconds */
@@ -11660,6 +11793,7 @@ export type Plans_Stream_Cursor_Value_Input = {
featureBackupEnabled?: InputMaybe<Scalars['Boolean']>; featureBackupEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomDomainsEnabled?: InputMaybe<Scalars['Boolean']>; featureCustomDomainsEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomEmailTemplatesEnabled?: InputMaybe<Scalars['Boolean']>; featureCustomEmailTemplatesEnabled?: InputMaybe<Scalars['Boolean']>;
featureCustomResources?: InputMaybe<Scalars['Boolean']>;
/** Weather or not to deploy email templates for git deployments */ /** Weather or not to deploy email templates for git deployments */
featureDeployEmailTemplates?: InputMaybe<Scalars['Boolean']>; featureDeployEmailTemplates?: InputMaybe<Scalars['Boolean']>;
/** Function execution timeout in seconds */ /** Function execution timeout in seconds */
@@ -11703,6 +11837,8 @@ export enum Plans_Update_Column {
/** column name */ /** column name */
FeatureCustomEmailTemplatesEnabled = 'featureCustomEmailTemplatesEnabled', FeatureCustomEmailTemplatesEnabled = 'featureCustomEmailTemplatesEnabled',
/** column name */ /** column name */
FeatureCustomResources = 'featureCustomResources',
/** column name */
FeatureDeployEmailTemplates = 'featureDeployEmailTemplates', FeatureDeployEmailTemplates = 'featureDeployEmailTemplates',
/** column name */ /** column name */
FeatureFunctionExecutionTimeout = 'featureFunctionExecutionTimeout', FeatureFunctionExecutionTimeout = 'featureFunctionExecutionTimeout',
@@ -11911,6 +12047,13 @@ export type Query_Root = {
files: Array<Files>; files: Array<Files>;
/** fetch aggregated fields from the table: "storage.files" */ /** fetch aggregated fields from the table: "storage.files" */
filesAggregate: Files_Aggregate; filesAggregate: Files_Aggregate;
getCPUSecondsUsage: Metrics;
getEgressVolume: Metrics;
getFunctionsInvocations: Metrics;
getLogsVolume: Metrics;
getPostgresVolumeCapacity: Metrics;
getPostgresVolumeUsage: Metrics;
getTotalRequests: Metrics;
/** fetch data from the table: "github_app_installations" using primary key columns */ /** fetch data from the table: "github_app_installations" using primary key columns */
githubAppInstallation?: Maybe<GithubAppInstallations>; githubAppInstallation?: Maybe<GithubAppInstallations>;
/** fetch data from the table: "github_app_installations" */ /** fetch data from the table: "github_app_installations" */
@@ -11946,6 +12089,13 @@ export type Query_Root = {
regions_aggregate: Regions_Aggregate; regions_aggregate: Regions_Aggregate;
/** fetch data from the table: "regions" using primary key columns */ /** fetch data from the table: "regions" using primary key columns */
regions_by_pk?: Maybe<Regions>; regions_by_pk?: Maybe<Regions>;
/**
* Returns lists of apps that have some live traffic in the give time range.
* From defaults to 24 hours ago and to defaults to now.
*
* Requests that returned a 4xx or 5xx status code are not counted as live traffic.
*/
statsLiveApps: StatsLiveApps;
systemConfig?: Maybe<ConfigSystemConfig>; systemConfig?: Maybe<ConfigSystemConfig>;
systemConfigs: Array<ConfigAppSystemConfig>; systemConfigs: Array<ConfigAppSystemConfig>;
/** fetch data from the table: "auth.users" using primary key columns */ /** fetch data from the table: "auth.users" using primary key columns */
@@ -12475,6 +12625,54 @@ export type Query_RootFilesAggregateArgs = {
}; };
export type Query_RootGetCpuSecondsUsageArgs = {
appID: Scalars['String'];
from?: InputMaybe<Scalars['Timestamp']>;
to?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGetEgressVolumeArgs = {
appID: Scalars['String'];
from?: InputMaybe<Scalars['Timestamp']>;
subdomain: Scalars['String'];
to?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGetFunctionsInvocationsArgs = {
appID: Scalars['String'];
from?: InputMaybe<Scalars['Timestamp']>;
to?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGetLogsVolumeArgs = {
appID: Scalars['String'];
from?: InputMaybe<Scalars['Timestamp']>;
to?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGetPostgresVolumeCapacityArgs = {
appID: Scalars['String'];
t?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGetPostgresVolumeUsageArgs = {
appID: Scalars['String'];
t?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGetTotalRequestsArgs = {
appID: Scalars['String'];
from?: InputMaybe<Scalars['Timestamp']>;
to?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootGithubAppInstallationArgs = { export type Query_RootGithubAppInstallationArgs = {
id: Scalars['uuid']; id: Scalars['uuid'];
}; };
@@ -12598,6 +12796,12 @@ export type Query_RootRegions_By_PkArgs = {
}; };
export type Query_RootStatsLiveAppsArgs = {
from?: InputMaybe<Scalars['Timestamp']>;
to?: InputMaybe<Scalars['Timestamp']>;
};
export type Query_RootSystemConfigArgs = { export type Query_RootSystemConfigArgs = {
appID: Scalars['uuid']; appID: Scalars['uuid'];
}; };
@@ -16349,11 +16553,31 @@ export type GetAppProvisionStatusQueryVariables = Exact<{
export type GetAppProvisionStatusQuery = { __typename?: 'query_root', apps: Array<{ __typename?: 'apps', id: any, isProvisioned: boolean, subdomain: string, createdAt: any }> }; export type GetAppProvisionStatusQuery = { __typename?: 'query_root', apps: Array<{ __typename?: 'apps', id: any, isProvisioned: boolean, subdomain: string, createdAt: any }> };
export type GetProjectMetricsQueryVariables = Exact<{
appId: Scalars['String'];
subdomain: Scalars['String'];
from?: InputMaybe<Scalars['Timestamp']>;
to?: InputMaybe<Scalars['Timestamp']>;
}>;
export type GetProjectMetricsQuery = { __typename?: 'query_root', logsVolume: { __typename?: 'Metrics', value: any }, cpuSecondsUsage: { __typename?: 'Metrics', value: any }, functionInvocations: { __typename?: 'Metrics', value: any }, postgresVolumeCapacity: { __typename?: 'Metrics', value: any }, postgresVolumeUsage: { __typename?: 'Metrics', value: any }, totalRequests: { __typename?: 'Metrics', value: any }, egressVolume: { __typename?: 'Metrics', value: any } };
export type GetRemoteAppRolesQueryVariables = Exact<{ [key: string]: never; }>; export type GetRemoteAppRolesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetRemoteAppRolesQuery = { __typename?: 'query_root', authRoles: Array<{ __typename?: 'authRoles', role: string }> }; export type GetRemoteAppRolesQuery = { __typename?: 'query_root', authRoles: Array<{ __typename?: 'authRoles', role: string }> };
export type WorkspaceFragment = { __typename?: 'workspaces', id: any, name: string, slug: string, workspaceMembers: Array<{ __typename?: 'workspaceMembers', id: any, type: string, user: { __typename?: 'users', id: any, email?: any | null, displayName: string } }>, projects: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> };
export type GetWorkspaceAndProjectQueryVariables = Exact<{
workspaceSlug: Scalars['String'];
projectSlug?: InputMaybe<Scalars['String']>;
}>;
export type GetWorkspaceAndProjectQuery = { __typename?: 'query_root', workspaces: Array<{ __typename?: 'workspaces', id: any, name: string, slug: string, workspaceMembers: Array<{ __typename?: 'workspaceMembers', id: any, type: string, user: { __typename?: 'users', id: any, email?: any | null, displayName: string } }>, projects: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> }>, projects: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> };
export type InsertApplicationMutationVariables = Exact<{ export type InsertApplicationMutationVariables = Exact<{
app: Apps_Insert_Input; app: Apps_Insert_Input;
}>; }>;
@@ -16361,6 +16585,13 @@ export type InsertApplicationMutationVariables = Exact<{
export type InsertApplicationMutation = { __typename?: 'mutation_root', insertApp?: { __typename?: 'apps', id: any, name: string, slug: string, workspace: { __typename?: 'workspaces', id: any, name: string, slug: string } } | null }; export type InsertApplicationMutation = { __typename?: 'mutation_root', insertApp?: { __typename?: 'apps', id: any, name: string, slug: string, workspace: { __typename?: 'workspaces', id: any, name: string, slug: string } } | null };
export type PauseApplicationMutationVariables = Exact<{
appId: Scalars['uuid'];
}>;
export type PauseApplicationMutation = { __typename?: 'mutation_root', updateApp?: { __typename?: 'apps', id: any } | null };
export type PrefetchNewAppRegionsFragment = { __typename?: 'regions', id: any, city: string, active: boolean, country: { __typename?: 'countries', code: any, name: string } }; export type PrefetchNewAppRegionsFragment = { __typename?: 'regions', id: any, city: string, active: boolean, country: { __typename?: 'countries', code: any, name: string } };
export type PrefetchNewAppPlansFragment = { __typename?: 'plans', id: any, name: string, isDefault: boolean, isFree: boolean, price: number, featureBackupEnabled: boolean, featureCustomDomainsEnabled: boolean, featureMaxDbSize: number }; export type PrefetchNewAppPlansFragment = { __typename?: 'plans', id: any, name: string, isDefault: boolean, isFree: boolean, price: number, featureBackupEnabled: boolean, featureCustomDomainsEnabled: boolean, featureMaxDbSize: number };
@@ -16437,7 +16668,7 @@ export type GetSignInMethodsQueryVariables = Exact<{
}>; }>;
export type GetSignInMethodsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', provider?: { __typename: 'ConfigProvider', id: 'ConfigProvider', sms?: { __typename?: 'ConfigSms', accountSid: string, authToken: string, messagingServiceId: string, provider?: string | null } | null } | null, auth?: { __typename: 'ConfigAuth', id: 'ConfigAuth', method?: { __typename?: 'ConfigAuthMethod', emailPassword?: { __typename?: 'ConfigAuthMethodEmailPassword', emailVerificationRequired?: boolean | null, hibpEnabled?: boolean | null } | null, emailPasswordless?: { __typename?: 'ConfigAuthMethodEmailPasswordless', enabled?: boolean | null } | null, smsPasswordless?: { __typename?: 'ConfigAuthMethodSmsPasswordless', enabled?: boolean | null } | null, anonymous?: { __typename?: 'ConfigAuthMethodAnonymous', enabled?: boolean | null } | null, webauthn?: { __typename?: 'ConfigAuthMethodWebauthn', enabled?: boolean | null } | null, oauth?: { __typename?: 'ConfigAuthMethodOauth', apple?: { __typename?: 'ConfigAuthMethodOauthApple', enabled?: boolean | null, clientId?: string | null, keyId?: string | null, teamId?: string | null, privateKey?: string | null } | null, discord?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, facebook?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, github?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, google?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, linkedin?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, spotify?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, twitch?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, twitter?: { __typename?: 'ConfigAuthMethodOauthTwitter', enabled?: boolean | null, consumerKey?: string | null, consumerSecret?: string | null } | null, windowslive?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, workos?: { __typename?: 'ConfigAuthMethodOauthWorkos', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, connection?: string | null, organization?: string | null } | null } | null } | null } | null } | null }; export type GetSignInMethodsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', provider?: { __typename: 'ConfigProvider', id: 'ConfigProvider', sms?: { __typename?: 'ConfigSms', accountSid: string, authToken: string, messagingServiceId: string, provider?: string | null } | null } | null, auth?: { __typename: 'ConfigAuth', id: 'ConfigAuth', method?: { __typename?: 'ConfigAuthMethod', emailPassword?: { __typename?: 'ConfigAuthMethodEmailPassword', emailVerificationRequired?: boolean | null, hibpEnabled?: boolean | null } | null, emailPasswordless?: { __typename?: 'ConfigAuthMethodEmailPasswordless', enabled?: boolean | null } | null, smsPasswordless?: { __typename?: 'ConfigAuthMethodSmsPasswordless', enabled?: boolean | null } | null, anonymous?: { __typename?: 'ConfigAuthMethodAnonymous', enabled?: boolean | null } | null, webauthn?: { __typename?: 'ConfigAuthMethodWebauthn', enabled?: boolean | null } | null, oauth?: { __typename?: 'ConfigAuthMethodOauth', apple?: { __typename?: 'ConfigAuthMethodOauthApple', enabled?: boolean | null, clientId?: string | null, keyId?: string | null, teamId?: string | null, privateKey?: string | null } | null, discord?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, facebook?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, github?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, google?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, linkedin?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, spotify?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, twitch?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, twitter?: { __typename?: 'ConfigAuthMethodOauthTwitter', enabled?: boolean | null, consumerKey?: string | null, consumerSecret?: string | null } | null, windowslive?: { __typename?: 'ConfigStandardOauthProviderWithScope', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, scope?: Array<string> | null } | null, workos?: { __typename?: 'ConfigAuthMethodOauthWorkos', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, connection?: string | null, organization?: string | null } | null, azuread?: { __typename?: 'ConfigAuthMethodOauthAzuread', enabled?: boolean | null, clientId?: string | null, clientSecret?: string | null, tenant?: string | null } | null } | null } | null } | null } | null };
export type GetSmtpSettingsQueryVariables = Exact<{ export type GetSmtpSettingsQueryVariables = Exact<{
appId: Scalars['uuid']; appId: Scalars['uuid'];
@@ -16454,6 +16685,13 @@ export type UpdateConfigMutationVariables = Exact<{
export type UpdateConfigMutation = { __typename?: 'mutation_root', updateConfig: { __typename?: 'ConfigConfig', id: 'ConfigConfig' } }; export type UpdateConfigMutation = { __typename?: 'mutation_root', updateConfig: { __typename?: 'ConfigConfig', id: 'ConfigConfig' } };
export type UnpauseApplicationMutationVariables = Exact<{
appId: Scalars['uuid'];
}>;
export type UnpauseApplicationMutation = { __typename?: 'mutation_root', updateApp?: { __typename?: 'apps', id: any } | null };
export type UpdateAppMutationVariables = Exact<{ export type UpdateAppMutationVariables = Exact<{
id: Scalars['uuid']; id: Scalars['uuid'];
app: Apps_Set_Input; app: Apps_Set_Input;
@@ -16567,6 +16805,8 @@ export type GetFilesAggregateQueryVariables = Exact<{
export type GetFilesAggregateQuery = { __typename?: 'query_root', filesAggregate: { __typename?: 'files_aggregate', aggregate?: { __typename?: 'files_aggregate_fields', count: number } | null } }; export type GetFilesAggregateQuery = { __typename?: 'query_root', filesAggregate: { __typename?: 'files_aggregate', aggregate?: { __typename?: 'files_aggregate_fields', count: number } | null } };
export type ProjectFragment = { __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null };
export type GithubRepositoryFragment = { __typename?: 'githubRepositories', id: any, name: string, fullName: string, private: boolean, githubAppInstallation: { __typename?: 'githubAppInstallations', id: any, accountLogin?: string | null, accountType?: string | null, accountAvatarUrl?: string | null } }; export type GithubRepositoryFragment = { __typename?: 'githubRepositories', id: any, name: string, fullName: string, private: boolean, githubAppInstallation: { __typename?: 'githubAppInstallations', id: any, accountLogin?: string | null, accountType?: string | null, accountAvatarUrl?: string | null } };
export type GetGithubRepositoriesQueryVariables = Exact<{ [key: string]: never; }>; export type GetGithubRepositoriesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -16784,14 +17024,19 @@ export type GetAvatarQueryVariables = Exact<{
export type GetAvatarQuery = { __typename?: 'query_root', user?: { __typename?: 'users', id: any, avatarUrl: string } | null }; export type GetAvatarQuery = { __typename?: 'query_root', user?: { __typename?: 'users', id: any, avatarUrl: string } | null };
export type ProjectFragment = { __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }> }; export type GetFreeAndActiveProjectsQueryVariables = Exact<{
userId: Scalars['uuid'];
}>;
export type GetFreeAndActiveProjectsQuery = { __typename?: 'query_root', freeAndActiveProjects: Array<{ __typename?: 'apps', id: any }> };
export type GetOneUserQueryVariables = Exact<{ export type GetOneUserQueryVariables = Exact<{
userId: Scalars['uuid']; userId: Scalars['uuid'];
}>; }>;
export type GetOneUserQuery = { __typename?: 'query_root', user?: { __typename?: 'users', id: any, displayName: string, avatarUrl: string, workspaceMembers: Array<{ __typename?: 'workspaceMembers', id: any, userId: any, workspaceId: any, type: string, workspace: { __typename?: 'workspaces', creatorUserId?: any | null, id: any, slug: string, name: string, apps: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }> }> } }> } | null }; export type GetOneUserQuery = { __typename?: 'query_root', user?: { __typename?: 'users', id: any, displayName: string, avatarUrl: string, workspaceMembers: Array<{ __typename?: 'workspaceMembers', id: any, userId: any, workspaceId: any, type: string, workspace: { __typename?: 'workspaces', creatorUserId?: any | null, id: any, slug: string, name: string, apps: Array<{ __typename?: 'apps', id: any, slug: string, name: string, repositoryProductionBranch: string, subdomain: string, isProvisioned: boolean, createdAt: any, desiredState: number, nhostBaseFolder: string, providersUpdated?: boolean | null, config?: { __typename?: 'ConfigConfig', hasura: { __typename?: 'ConfigHasura', adminSecret: string } } | null, featureFlags: Array<{ __typename?: 'featureFlags', description: string, id: any, name: string, value: string }>, appStates: Array<{ __typename?: 'appStateHistory', id: any, appId: any, message?: string | null, stateId: number, createdAt: any }>, region: { __typename?: 'regions', id: any, countryCode: string, awsName: string, city: string }, plan: { __typename?: 'plans', id: any, name: string, isFree: boolean }, githubRepository?: { __typename?: 'githubRepositories', fullName: string } | null, deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, commitMessage?: string | null, commitUserName?: string | null, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, commitUserAvatarUrl?: string | null, deploymentStatus?: string | null }>, creator?: { __typename?: 'users', id: any, email?: any | null, displayName: string } | null }> } }> } | null };
export type GetUserAllWorkspacesQueryVariables = Exact<{ [key: string]: never; }>; export type GetUserAllWorkspacesQueryVariables = Exact<{ [key: string]: never; }>;
@@ -16993,6 +17238,86 @@ export const GetAppByWorkspaceAndNameFragmentDoc = gql`
} }
} }
`; `;
export const ProjectFragmentDoc = gql`
fragment Project on apps {
id
slug
name
repositoryProductionBranch
subdomain
isProvisioned
createdAt
desiredState
nhostBaseFolder
providersUpdated
config(resolve: true) {
hasura {
adminSecret
}
}
featureFlags {
description
id
name
value
}
appStates(order_by: {createdAt: desc}, limit: 1) {
id
appId
message
stateId
createdAt
}
region {
id
countryCode
awsName
city
}
plan {
id
name
isFree
}
githubRepository {
fullName
}
deployments(limit: 4, order_by: {deploymentEndedAt: desc}) {
id
commitSHA
commitMessage
commitUserName
deploymentStartedAt
deploymentEndedAt
commitUserAvatarUrl
deploymentStatus
}
creator {
id
email
displayName
}
}
`;
export const WorkspaceFragmentDoc = gql`
fragment Workspace on workspaces {
id
name
slug
workspaceMembers {
id
user {
id
email
displayName
}
type
}
projects: apps {
...Project
}
}
${ProjectFragmentDoc}`;
export const PrefetchNewAppRegionsFragmentDoc = gql` export const PrefetchNewAppRegionsFragmentDoc = gql`
fragment PrefetchNewAppRegions on regions { fragment PrefetchNewAppRegions on regions {
id id
@@ -17161,62 +17486,6 @@ export const RemoteAppGetUsersFragmentDoc = gql`
disabled disabled
} }
`; `;
export const ProjectFragmentDoc = gql`
fragment Project on apps {
id
slug
name
repositoryProductionBranch
subdomain
isProvisioned
createdAt
desiredState
nhostBaseFolder
providersUpdated
config(resolve: true) {
hasura {
adminSecret
}
}
featureFlags {
description
id
name
value
}
appStates(order_by: {createdAt: desc}, limit: 1) {
id
appId
message
stateId
createdAt
}
region {
id
countryCode
awsName
city
}
plan {
id
name
isFree
}
githubRepository {
fullName
}
deployments(limit: 4, order_by: {deploymentEndedAt: desc}) {
id
commitSHA
commitMessage
commitUserName
deploymentStartedAt
deploymentEndedAt
commitUserAvatarUrl
deploymentStatus
}
}
`;
export const GetWorkspaceMembersWorkspaceMemberFragmentDoc = gql` export const GetWorkspaceMembersWorkspaceMemberFragmentDoc = gql`
fragment getWorkspaceMembersWorkspaceMember on workspaceMembers { fragment getWorkspaceMembersWorkspaceMember on workspaceMembers {
id id
@@ -17652,6 +17921,74 @@ export type GetAppProvisionStatusQueryResult = Apollo.QueryResult<GetAppProvisio
export function refetchGetAppProvisionStatusQuery(variables: GetAppProvisionStatusQueryVariables) { export function refetchGetAppProvisionStatusQuery(variables: GetAppProvisionStatusQueryVariables) {
return { query: GetAppProvisionStatusDocument, variables: variables } return { query: GetAppProvisionStatusDocument, variables: variables }
} }
export const GetProjectMetricsDocument = gql`
query GetProjectMetrics($appId: String!, $subdomain: String!, $from: Timestamp, $to: Timestamp) {
logsVolume: getLogsVolume(appID: $appId, from: $from, to: $to) {
value
}
cpuSecondsUsage: getCPUSecondsUsage(appID: $appId, from: $from, to: $to) {
value
}
functionInvocations: getFunctionsInvocations(
appID: $appId
from: $from
to: $to
) {
value
}
postgresVolumeCapacity: getPostgresVolumeCapacity(appID: $appId) {
value
}
postgresVolumeUsage: getPostgresVolumeUsage(appID: $appId) {
value
}
totalRequests: getTotalRequests(appID: $appId, from: $from, to: $to) {
value
}
egressVolume: getEgressVolume(
appID: $appId
subdomain: $subdomain
from: $from
to: $to
) {
value
}
}
`;
/**
* __useGetProjectMetricsQuery__
*
* To run a query within a React component, call `useGetProjectMetricsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetProjectMetricsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetProjectMetricsQuery({
* variables: {
* appId: // value for 'appId'
* subdomain: // value for 'subdomain'
* from: // value for 'from'
* to: // value for 'to'
* },
* });
*/
export function useGetProjectMetricsQuery(baseOptions: Apollo.QueryHookOptions<GetProjectMetricsQuery, GetProjectMetricsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetProjectMetricsQuery, GetProjectMetricsQueryVariables>(GetProjectMetricsDocument, options);
}
export function useGetProjectMetricsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetProjectMetricsQuery, GetProjectMetricsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetProjectMetricsQuery, GetProjectMetricsQueryVariables>(GetProjectMetricsDocument, options);
}
export type GetProjectMetricsQueryHookResult = ReturnType<typeof useGetProjectMetricsQuery>;
export type GetProjectMetricsLazyQueryHookResult = ReturnType<typeof useGetProjectMetricsLazyQuery>;
export type GetProjectMetricsQueryResult = Apollo.QueryResult<GetProjectMetricsQuery, GetProjectMetricsQueryVariables>;
export function refetchGetProjectMetricsQuery(variables: GetProjectMetricsQueryVariables) {
return { query: GetProjectMetricsDocument, variables: variables }
}
export const GetRemoteAppRolesDocument = gql` export const GetRemoteAppRolesDocument = gql`
query getRemoteAppRoles { query getRemoteAppRoles {
authRoles { authRoles {
@@ -17689,6 +18026,49 @@ export type GetRemoteAppRolesQueryResult = Apollo.QueryResult<GetRemoteAppRolesQ
export function refetchGetRemoteAppRolesQuery(variables?: GetRemoteAppRolesQueryVariables) { export function refetchGetRemoteAppRolesQuery(variables?: GetRemoteAppRolesQueryVariables) {
return { query: GetRemoteAppRolesDocument, variables: variables } return { query: GetRemoteAppRolesDocument, variables: variables }
} }
export const GetWorkspaceAndProjectDocument = gql`
query GetWorkspaceAndProject($workspaceSlug: String!, $projectSlug: String) {
workspaces(where: {slug: {_eq: $workspaceSlug}}) {
...Workspace
}
projects: apps(where: {slug: {_eq: $projectSlug}}) {
...Project
}
}
${WorkspaceFragmentDoc}
${ProjectFragmentDoc}`;
/**
* __useGetWorkspaceAndProjectQuery__
*
* To run a query within a React component, call `useGetWorkspaceAndProjectQuery` and pass it any options that fit your needs.
* When your component renders, `useGetWorkspaceAndProjectQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetWorkspaceAndProjectQuery({
* variables: {
* workspaceSlug: // value for 'workspaceSlug'
* projectSlug: // value for 'projectSlug'
* },
* });
*/
export function useGetWorkspaceAndProjectQuery(baseOptions: Apollo.QueryHookOptions<GetWorkspaceAndProjectQuery, GetWorkspaceAndProjectQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetWorkspaceAndProjectQuery, GetWorkspaceAndProjectQueryVariables>(GetWorkspaceAndProjectDocument, options);
}
export function useGetWorkspaceAndProjectLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetWorkspaceAndProjectQuery, GetWorkspaceAndProjectQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetWorkspaceAndProjectQuery, GetWorkspaceAndProjectQueryVariables>(GetWorkspaceAndProjectDocument, options);
}
export type GetWorkspaceAndProjectQueryHookResult = ReturnType<typeof useGetWorkspaceAndProjectQuery>;
export type GetWorkspaceAndProjectLazyQueryHookResult = ReturnType<typeof useGetWorkspaceAndProjectLazyQuery>;
export type GetWorkspaceAndProjectQueryResult = Apollo.QueryResult<GetWorkspaceAndProjectQuery, GetWorkspaceAndProjectQueryVariables>;
export function refetchGetWorkspaceAndProjectQuery(variables: GetWorkspaceAndProjectQueryVariables) {
return { query: GetWorkspaceAndProjectDocument, variables: variables }
}
export const InsertApplicationDocument = gql` export const InsertApplicationDocument = gql`
mutation insertApplication($app: apps_insert_input!) { mutation insertApplication($app: apps_insert_input!) {
insertApp(object: $app) { insertApp(object: $app) {
@@ -17729,6 +18109,39 @@ export function useInsertApplicationMutation(baseOptions?: Apollo.MutationHookOp
export type InsertApplicationMutationHookResult = ReturnType<typeof useInsertApplicationMutation>; export type InsertApplicationMutationHookResult = ReturnType<typeof useInsertApplicationMutation>;
export type InsertApplicationMutationResult = Apollo.MutationResult<InsertApplicationMutation>; export type InsertApplicationMutationResult = Apollo.MutationResult<InsertApplicationMutation>;
export type InsertApplicationMutationOptions = Apollo.BaseMutationOptions<InsertApplicationMutation, InsertApplicationMutationVariables>; export type InsertApplicationMutationOptions = Apollo.BaseMutationOptions<InsertApplicationMutation, InsertApplicationMutationVariables>;
export const PauseApplicationDocument = gql`
mutation PauseApplication($appId: uuid!) {
updateApp(pk_columns: {id: $appId}, _set: {desiredState: 6}) {
id
}
}
`;
export type PauseApplicationMutationFn = Apollo.MutationFunction<PauseApplicationMutation, PauseApplicationMutationVariables>;
/**
* __usePauseApplicationMutation__
*
* To run a mutation, you first call `usePauseApplicationMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `usePauseApplicationMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [pauseApplicationMutation, { data, loading, error }] = usePauseApplicationMutation({
* variables: {
* appId: // value for 'appId'
* },
* });
*/
export function usePauseApplicationMutation(baseOptions?: Apollo.MutationHookOptions<PauseApplicationMutation, PauseApplicationMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<PauseApplicationMutation, PauseApplicationMutationVariables>(PauseApplicationDocument, options);
}
export type PauseApplicationMutationHookResult = ReturnType<typeof usePauseApplicationMutation>;
export type PauseApplicationMutationResult = Apollo.MutationResult<PauseApplicationMutation>;
export type PauseApplicationMutationOptions = Apollo.BaseMutationOptions<PauseApplicationMutation, PauseApplicationMutationVariables>;
export const PrefetchNewAppDocument = gql` export const PrefetchNewAppDocument = gql`
query PrefetchNewApp { query PrefetchNewApp {
regions(order_by: {city: asc}) { regions(order_by: {city: asc}) {
@@ -18192,6 +18605,12 @@ export const GetSignInMethodsDocument = gql`
connection connection
organization organization
} }
azuread {
enabled
clientId
clientSecret
tenant
}
} }
} }
} }
@@ -18314,6 +18733,39 @@ export function useUpdateConfigMutation(baseOptions?: Apollo.MutationHookOptions
export type UpdateConfigMutationHookResult = ReturnType<typeof useUpdateConfigMutation>; export type UpdateConfigMutationHookResult = ReturnType<typeof useUpdateConfigMutation>;
export type UpdateConfigMutationResult = Apollo.MutationResult<UpdateConfigMutation>; export type UpdateConfigMutationResult = Apollo.MutationResult<UpdateConfigMutation>;
export type UpdateConfigMutationOptions = Apollo.BaseMutationOptions<UpdateConfigMutation, UpdateConfigMutationVariables>; export type UpdateConfigMutationOptions = Apollo.BaseMutationOptions<UpdateConfigMutation, UpdateConfigMutationVariables>;
export const UnpauseApplicationDocument = gql`
mutation UnpauseApplication($appId: uuid!) {
updateApp(pk_columns: {id: $appId}, _set: {desiredState: 5}) {
id
}
}
`;
export type UnpauseApplicationMutationFn = Apollo.MutationFunction<UnpauseApplicationMutation, UnpauseApplicationMutationVariables>;
/**
* __useUnpauseApplicationMutation__
*
* To run a mutation, you first call `useUnpauseApplicationMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useUnpauseApplicationMutation` returns a tuple that includes:
* - A mutate function that you can call at any time to execute the mutation
* - An object with fields that represent the current status of the mutation's execution
*
* @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
*
* @example
* const [unpauseApplicationMutation, { data, loading, error }] = useUnpauseApplicationMutation({
* variables: {
* appId: // value for 'appId'
* },
* });
*/
export function useUnpauseApplicationMutation(baseOptions?: Apollo.MutationHookOptions<UnpauseApplicationMutation, UnpauseApplicationMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<UnpauseApplicationMutation, UnpauseApplicationMutationVariables>(UnpauseApplicationDocument, options);
}
export type UnpauseApplicationMutationHookResult = ReturnType<typeof useUnpauseApplicationMutation>;
export type UnpauseApplicationMutationResult = Apollo.MutationResult<UnpauseApplicationMutation>;
export type UnpauseApplicationMutationOptions = Apollo.BaseMutationOptions<UnpauseApplicationMutation, UnpauseApplicationMutationVariables>;
export const UpdateAppDocument = gql` export const UpdateAppDocument = gql`
mutation updateApp($id: uuid!, $app: apps_set_input!) { mutation updateApp($id: uuid!, $app: apps_set_input!) {
updateApp(pk_columns: {id: $id}, _set: $app) { updateApp(pk_columns: {id: $id}, _set: $app) {
@@ -20072,6 +20524,46 @@ export type GetAvatarQueryResult = Apollo.QueryResult<GetAvatarQuery, GetAvatarQ
export function refetchGetAvatarQuery(variables: GetAvatarQueryVariables) { export function refetchGetAvatarQuery(variables: GetAvatarQueryVariables) {
return { query: GetAvatarDocument, variables: variables } return { query: GetAvatarDocument, variables: variables }
} }
export const GetFreeAndActiveProjectsDocument = gql`
query GetFreeAndActiveProjects($userId: uuid!) {
freeAndActiveProjects: apps(
where: {creatorUserId: {_eq: $userId}, plan: {isFree: {_eq: true}}, desiredState: {_eq: 5}}
) {
id
}
}
`;
/**
* __useGetFreeAndActiveProjectsQuery__
*
* To run a query within a React component, call `useGetFreeAndActiveProjectsQuery` and pass it any options that fit your needs.
* When your component renders, `useGetFreeAndActiveProjectsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useGetFreeAndActiveProjectsQuery({
* variables: {
* userId: // value for 'userId'
* },
* });
*/
export function useGetFreeAndActiveProjectsQuery(baseOptions: Apollo.QueryHookOptions<GetFreeAndActiveProjectsQuery, GetFreeAndActiveProjectsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<GetFreeAndActiveProjectsQuery, GetFreeAndActiveProjectsQueryVariables>(GetFreeAndActiveProjectsDocument, options);
}
export function useGetFreeAndActiveProjectsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<GetFreeAndActiveProjectsQuery, GetFreeAndActiveProjectsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<GetFreeAndActiveProjectsQuery, GetFreeAndActiveProjectsQueryVariables>(GetFreeAndActiveProjectsDocument, options);
}
export type GetFreeAndActiveProjectsQueryHookResult = ReturnType<typeof useGetFreeAndActiveProjectsQuery>;
export type GetFreeAndActiveProjectsLazyQueryHookResult = ReturnType<typeof useGetFreeAndActiveProjectsLazyQuery>;
export type GetFreeAndActiveProjectsQueryResult = Apollo.QueryResult<GetFreeAndActiveProjectsQuery, GetFreeAndActiveProjectsQueryVariables>;
export function refetchGetFreeAndActiveProjectsQuery(variables: GetFreeAndActiveProjectsQueryVariables) {
return { query: GetFreeAndActiveProjectsDocument, variables: variables }
}
export const GetOneUserDocument = gql` export const GetOneUserDocument = gql`
query getOneUser($userId: uuid!) { query getOneUser($userId: uuid!) {
user(id: $userId) { user(id: $userId) {

View File

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

View File

@@ -0,0 +1,79 @@
import prettifyNumber from './prettifyNumber';
test('should throw an error if multiplier is lower than 0', () => {
expect(() => prettifyNumber(1000, { multiplier: -1 })).toThrowError(
'Multiplier must be greater than 0',
);
});
test('should return the input value if it is lower than the multiplier', () => {
expect(prettifyNumber(980)).toBe('980');
expect(prettifyNumber(-980)).toBe('-980');
expect(prettifyNumber(1500, { multiplier: 2000 })).toBe('1500');
expect(prettifyNumber(-1500, { multiplier: 2000 })).toBe('-1500');
});
test('should return the converted value', () => {
expect(prettifyNumber(1000)).toBe('1k');
expect(prettifyNumber(-1000)).toBe('-1k');
expect(prettifyNumber(1420)).toBe('1.42k');
expect(prettifyNumber(-1420)).toBe('-1.42k');
expect(prettifyNumber(54091776)).toBe('54.09M');
expect(prettifyNumber(-54091776)).toBe('-54.09M');
expect(prettifyNumber(23475400000)).toBe('23.48B');
expect(prettifyNumber(-23475400000)).toBe('-23.48B');
});
test('should return the converted value with custom multiplier', () => {
expect(prettifyNumber(1024, { multiplier: 1024 })).toBe('1k');
expect(prettifyNumber(1420, { multiplier: 1024 })).toBe('1.39k');
expect(prettifyNumber(54091776, { multiplier: 1024 })).toBe('51.59M');
expect(prettifyNumber(23475400000, { multiplier: 1024 })).toBe('21.86B');
});
test('should be able to change the labels', () => {
const customLabels = [
'Bytes',
'KiB',
'MiB',
'GiB',
'TiB',
'PiB',
'EiB',
'ZiB',
'YiB',
];
expect(prettifyNumber(1024, { labels: customLabels, multiplier: 1024 })).toBe(
'1KiB',
);
expect(prettifyNumber(1420, { labels: customLabels, multiplier: 1024 })).toBe(
'1.39KiB',
);
expect(
prettifyNumber(54091776, { labels: customLabels, multiplier: 1024 }),
).toBe('51.59MiB');
expect(
prettifyNumber(23475400000, { labels: customLabels, multiplier: 1024 }),
).toBe('21.86GiB');
});
test('should be able to change the separator', () => {
expect(prettifyNumber(1024, { separator: ' ' })).toBe('1.02 k');
expect(prettifyNumber(1420, { separator: ' ' })).toBe('1.42 k');
expect(prettifyNumber(54091776, { separator: ' ' })).toBe('54.09 M');
expect(prettifyNumber(23475400000, { separator: ' ' })).toBe('23.48 B');
});
test('should be able to change the number of decimals', () => {
expect(prettifyNumber(1024, { numberOfDecimals: 0 })).toBe('1k');
expect(prettifyNumber(1420, { numberOfDecimals: 0 })).toBe('1k');
expect(prettifyNumber(54091776, { numberOfDecimals: 0 })).toBe('54M');
expect(prettifyNumber(23475400000, { numberOfDecimals: 0 })).toBe('23B');
});
test('should always use the last available label if the value is too large', () => {
expect(prettifyNumber(10000000, { labels: ['Bytes', 'KB'] })).toBe('10000KB');
expect(prettifyNumber(10000, { labels: [] })).toBe('10000');
});

View File

@@ -0,0 +1,90 @@
export interface PrettifyNumberOptions {
/**
* Multiplier for value. Useful if you want to convert to a different unit.
* Must be greater than 0.
*
* @default 1000
*/
multiplier?: number;
/**
* Labels used for prettified numbers.
*
* @default ['','k','M','B','T']
*/
labels?: string[];
/**
* Maximum number of decimals to use.
*
* @default 2
*/
numberOfDecimals?: number;
/**
* Separator between value and label.
*
* @default ''
*/
separator?: string;
}
function formatValue(value: number, numberOfDecimals: number) {
return value.toLocaleString(undefined, {
minimumFractionDigits: 0,
maximumFractionDigits: numberOfDecimals,
useGrouping: false,
});
}
/**
* Prettify a `value`. For every `multiplier` the value will be divided and the
* next label will be used.
*
* @example
* ```js
* 1000 => '1k' // Given that "k" is the next label
* 1000000 => '1M' // Given that "M" is the next label
* 1234567 => '1.23M' // Given that "M" is the next label
* ```
*
* @param value - Value to prettify
* @param options - Configuration options
* @returns Prettified value
*/
export default function prettifyNumber(
value: number,
options?: PrettifyNumberOptions,
) {
const {
multiplier = 1000,
numberOfDecimals = 2,
labels = ['', 'k', 'M', 'B', 'T'],
separator = '',
} = options || {};
if (multiplier < 0) {
throw new Error('Multiplier must be greater than 0');
}
if (Math.abs(value) < multiplier) {
const label = labels?.[0];
return [formatValue(value, numberOfDecimals), label]
.filter(Boolean)
.join(separator)
.trim();
}
// Power should be between 0 and the length of the labels array
const power = Math.min(
Math.max(labels.length - 1, 0),
Math.floor(Math.log(Math.abs(value)) / Math.log(multiplier)),
);
const formattedValue = formatValue(
value / multiplier ** power,
numberOfDecimals,
);
const label = labels?.[power];
return [formattedValue, label].filter(Boolean).join(separator).trim();
}

View File

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

View File

@@ -0,0 +1,20 @@
import type { PrettifyNumberOptions } from '@/utils/common/prettifyNumber';
import { prettifyNumber } from '@/utils/common/prettifyNumber';
/**
* Prettify a size value in bytes.
*
* @param size - Size in bytes
* @param options - Configuration options
* @returns Prettified size
*/
export default function prettifySize(
size: number,
options?: PrettifyNumberOptions,
) {
return prettifyNumber(size, {
labels: ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'],
separator: ' ',
...options,
});
}

View File

@@ -7,7 +7,7 @@ export interface ColumnDetails {
hasDefaultValue: boolean; hasDefaultValue: boolean;
} }
function createGenericValidationSchema<T extends yup.BaseSchema>( function createGenericValidationSchema<T extends yup.Schema>(
genericSchema: T, genericSchema: T,
{ isNullable, hasDefaultValue, isIdentity }: ColumnDetails, { isNullable, hasDefaultValue, isIdentity }: ColumnDetails,
): T { ): T {
@@ -136,7 +136,10 @@ export function createDynamicValidationSchema(
}; };
} }
if (column.type === 'text' && column.specificType === 'jsonb') { if (
column.type === 'text' &&
(column.specificType === 'jsonb' || column.specificType === 'json')
) {
return { return {
...schema, ...schema,
[column.id]: createJSONValidationSchema(details), [column.id]: createJSONValidationSchema(details),

View File

@@ -4,6 +4,10 @@ import { isDevOrStaging } from './helpers';
* @param content {string} This string to log on the particular channel. * @param content {string} This string to log on the particular channel.
*/ */
export const discordAnnounce = async (content: string) => { export const discordAnnounce = async (content: string) => {
if (!process.env.NEXT_PUBLIC_DISCORD_LOGGING) {
return;
}
const username = isDevOrStaging() ? 'console-next(dev)' : 'console-next'; const username = isDevOrStaging() ? 'console-next(dev)' : 'console-next';
const params = { const params = {

View File

@@ -8,18 +8,12 @@ type AcceptedState =
| ApplicationStatus.Updating; | ApplicationStatus.Updating;
function checkIfAcceptedState(appState: ApplicationStatus) { function checkIfAcceptedState(appState: ApplicationStatus) {
switch (appState) { return [
case ApplicationStatus.Provisioning: ApplicationStatus.Provisioning,
return true; ApplicationStatus.Unpausing,
case ApplicationStatus.Unpausing: ApplicationStatus.Pausing,
return true; ApplicationStatus.Updating,
case ApplicationStatus.Pausing: ].includes(appState);
return true;
case ApplicationStatus.Updating:
return true;
default:
return false;
}
} }
/** /**

View File

@@ -1,5 +1,5 @@
import { graphql } from 'msw'; import { graphql } from 'msw';
const nhostGraphQLLink = graphql.link('http://localhost:1337/v1/graphql'); const nhostGraphQLLink = graphql.link('https://local.graphql.nhost.run/v1');
export default nhostGraphQLLink; export default nhostGraphQLLink;

View File

@@ -79,7 +79,7 @@ function Providers({ children }: PropsWithChildren<{}>) {
<NhostApolloProvider <NhostApolloProvider
nhost={nhost} nhost={nhost}
link={createHttpLink({ link={createHttpLink({
uri: 'http://localhost:1337/v1/graphql', uri: 'https://local.graphql.nhost.run/v1',
})} })}
> >
<WorkspaceProvider> <WorkspaceProvider>

Some files were not shown because too many files have changed in this diff Show More