Compare commits

..

116 Commits

Author SHA1 Message Date
Szilárd Dóró
3c1996b13b Merge pull request #1543 from nhost/changeset-release/main
chore: update versions
2023-01-30 10:01:21 +01:00
github-actions[bot]
e3d90fd5d2 chore: update versions 2023-01-30 08:58:53 +00:00
Szilárd Dóró
af016e1caa Merge pull request #1548 from nhost/remove-functions-section
fix(dashboard): removed functions section
2023-01-30 09:57:31 +01:00
Szilárd Dóró
ed4c780115 fix(dashboard): lint error 2023-01-30 09:40:51 +01:00
Johan Eliasson
941f0f5755 removed unused code 2023-01-28 20:17:43 +01:00
Johan Eliasson
75344b2bc0 remove 2023-01-28 18:02:57 +01:00
Johan Eliasson
65da426e8b update 2023-01-28 17:46:18 +01:00
Johan Eliasson
f34702f3c5 Merge pull request #1541 from nhost/docs-graphql-integrations
stripe graphql api updates
2023-01-27 19:35:31 +01:00
Johan Eliasson
6cb70eee01 Update docs/docs/graphql/remote-schemas/stripe.mdx
Co-authored-by: Guido Curcio <guidomaurocurcio@gmail.com>
2023-01-27 16:12:43 +01:00
Johan Eliasson
9395c9687f update 2023-01-27 16:03:31 +01:00
Johan Eliasson
eb1eb934a4 update 2023-01-27 10:36:56 +01:00
Johan Eliasson
c62fed2c9a update 2023-01-27 09:16:24 +01:00
Johan Eliasson
16fe1a47da fixed broken links 2023-01-27 09:13:42 +01:00
Johan Eliasson
0f04e8b8b8 added example response 2023-01-27 09:07:38 +01:00
Johan Eliasson
e6dad4d696 added changeset 2023-01-27 08:57:30 +01:00
Johan Eliasson
bcb3b79add updates 2023-01-27 08:54:23 +01:00
Szilárd Dóró
fe658231b4 Merge pull request #1538 from nhost/changeset-release/main
chore: update versions
2023-01-23 10:37:11 +01:00
github-actions[bot]
a1188b7d98 chore: update versions 2023-01-23 09:10:50 +00:00
Szilárd Dóró
cd4bdc581d Merge pull request #1537 from nhost/fix/local-auth-page
fix(dashboard): don't break Auth page in local mode
2023-01-23 10:09:26 +01:00
Szilárd Dóró
4e2f8ccd52 fix(dashboard): don't break Auth page in local mode 2023-01-23 09:30:11 +01:00
Szilárd Dóró
8a6d8c7534 Merge pull request #1534 from nhost/changeset-release/main
chore: update versions
2023-01-19 08:17:58 +01:00
github-actions[bot]
fa75409f09 chore: update versions 2023-01-18 20:37:34 +00:00
Szilárd Dóró
74662052ae Merge pull request #1531 from nhost/fix/allowed-emails-and-domains
fix(dashboard): enable toggle when settings are filled in
2023-01-18 21:36:20 +01:00
Szilárd Dóró
37ab5fe878 trigger build 2023-01-18 17:50:28 +01:00
Szilárd Dóró
be9af96fa7 fix(dashboard): remove values if toggle is disabled 2023-01-18 16:42:44 +01:00
Szilárd Dóró
31abbe5f30 fix(dashboard): enable toggle when settings are filled in 2023-01-18 15:18:39 +01:00
Szilárd Dóró
268b461d5b Merge pull request #1529 from nhost/changeset-release/main
chore: update versions
2023-01-18 10:44:26 +01:00
github-actions[bot]
58af592cfa chore: update versions 2023-01-18 09:04:47 +00:00
Szilárd Dóró
0e9d623c69 Merge pull request #1527 from nhost/fix/permission-editor-array-input
fix(dashboard): don't throw validation error for valid permission rules
2023-01-18 10:03:35 +01:00
Szilárd Dóró
412a290646 Merge pull request #1528 from nhost/chore/lower-storage-page-limit
chore(dashboard): list fewer images per page on the Storage page
2023-01-18 09:35:15 +01:00
Szilárd Dóró
123add38a4 fix(dashboard): fetch images from correct URL 2023-01-17 16:43:34 +01:00
Szilárd Dóró
5bdd31ad36 chore(dashboard): list fewer images per page on the Storage page 2023-01-17 16:41:14 +01:00
Szilárd Dóró
5121851c8b fix(dashboard): don't throw validation error for valid permission rules 2023-01-17 16:35:29 +01:00
Szilárd Dóró
8ca1f92491 Merge pull request #1525 from nhost/changeset-release/main
chore: update versions
2023-01-17 14:20:25 +01:00
github-actions[bot]
5535b9085b chore: update versions 2023-01-17 10:20:52 +00:00
Szilárd Dóró
bc51122b25 Merge pull request #1522 from nhost/fix/retrigger-deployment-status
fix(dashboard): correct redeployment button
2023-01-17 11:19:38 +01:00
Szilárd Dóró
b060e5e550 fix(dashboard): restore deployment timer 2023-01-17 09:27:28 +01:00
Szilárd Dóró
6a906b22e2 fix(dashboard): show deployment duration 2023-01-17 09:04:43 +01:00
Pilou
860c9d1be4 Merge pull request #1523 from akd-io/patch-2
Docs: Fix npm install command
2023-01-16 18:33:44 +01:00
Anders Kjær Damgaard
9eec3e58f5 Fix npm install command
Was missing a space
2023-01-16 15:42:56 +01:00
Johan Eliasson
4e01a43e94 Merge pull request #1431 from nhost/example-updates
codegen example updates
2023-01-16 14:31:27 +01:00
Szilárd Dóró
c126b20dcf chore(dashboard): add changeset 2023-01-16 13:38:00 +01:00
Szilárd Dóró
b727a24a5f fix(dashboard): restore "Redeploy" button behavior 2023-01-16 12:37:32 +01:00
Szilárd Dóró
ecadd7e1b9 fix(dashboard): don't show redeploy button when deployment is in progress 2023-01-16 11:52:58 +01:00
Johan Eliasson
2d661174a8 update 2023-01-16 10:01:02 +01:00
Johan Eliasson
fcb3e5192f Merge branch 'main' into example-updates 2023-01-16 10:00:28 +01:00
Szilárd Dóró
66fdc63f38 Merge pull request #1516 from nhost/renovate/rimraf-4.x
chore(deps): update dependency rimraf to v4
2023-01-13 10:25:34 +01:00
renovate[bot]
fa37cb6171 chore(deps): update dependency rimraf to v4 2023-01-13 02:39:43 +00:00
Szilárd Dóró
c1bea1294d Merge pull request #1512 from nhost/changeset-release/main
chore: update versions
2023-01-12 15:46:08 +01:00
github-actions[bot]
8af2f6e9dd chore: update versions 2023-01-12 11:43:39 +00:00
Szilárd Dóró
e3d0b96917 Merge pull request #1503 from nhost/feat/retrigger-deployments
feat(dashboard): Retrigger Deployments
2023-01-12 12:41:55 +01:00
Szilárd Dóró
43705b992d Merge pull request #1509 from nhost/changeset-release/main
chore: update versions
2023-01-12 12:41:41 +01:00
github-actions[bot]
2e999e8715 chore: update versions 2023-01-12 10:14:41 +00:00
Pilou
0370696d5c Merge pull request #1511 from nhost/chore/unlink-packages
chore(changeset): stop linking packages
2023-01-12 11:12:44 +01:00
Pierre-Louis Mercereau
f62131d55a chore(changeset): stop linking packages 2023-01-12 10:59:57 +01:00
Szilárd Dóró
36c3519cf8 chore(dashboard): retrigger deployments 2023-01-12 10:18:28 +01:00
Szilárd Dóró
86d077ac00 Merge pull request #1508 from nhost/renovate-changesets
chore: create changesest from Renovate bumps
2023-01-12 10:10:35 +01:00
szilarddoro
200e9f774c chore(deps): update dependency @types/react-dom to v18.0.10 2023-01-12 08:49:58 +00:00
Szilárd Dóró
9b52e9bf13 Merge branch 'main' into feat/retrigger-deployments 2023-01-12 09:49:46 +01:00
Szilárd Dóró
bc1235de3b Merge pull request #1433 from nhost/renovate/react-dom-18.x
chore(deps): update dependency @types/react-dom to v18.0.10
2023-01-12 09:48:10 +01:00
Szilárd Dóró
fce58ebaea remove changeset, CI generates it 2023-01-12 09:47:53 +01:00
Szilárd Dóró
452e281120 chore(dashboard): add changeset 2023-01-12 09:47:04 +01:00
Szilárd Dóró
9a338e54c9 Merge pull request #1492 from nhost/renovate/vitest-monorepo
chore(deps): update vitest monorepo to ^0.27.0
2023-01-12 09:45:14 +01:00
Szilárd Dóró
baeebf980d Merge pull request #1507 from nhost/changeset-release/main
chore: update versions
2023-01-12 09:27:25 +01:00
github-actions[bot]
ac92c6ee61 chore: update versions 2023-01-12 01:38:20 +00:00
Guido Curcio
1ddaf680c0 Merge pull request #1471 from nhost/fix(dashboard)/workspace-creation-redirection-delete 2023-01-11 22:36:50 -03:00
Guido Curcio
c6e6194d8e mutating -> updating for signaling changes in course. 2023-01-11 09:05:43 -03:00
Pilou
83deea8b45 Merge pull request #1501 from nhost/chore/exclude-functions-from-workspace
chore: exclude functions from workspace
2023-01-11 11:42:07 +01:00
Szilárd Dóró
07c8d90053 fix(dashboard): lint errors 2023-01-11 11:13:37 +01:00
Pierre-Louis Mercereau
acbaabcf85 chore: update lockfile 2023-01-11 10:44:36 +01:00
Szilárd Dóró
a2621e40a4 feat(dashboard): unified list items for deployments
fixed the way the latest scheduled or pending deployment is tracked
2023-01-11 10:37:10 +01:00
Pierre-Louis Mercereau
3534501f37 chore: force using turborepo v1 2023-01-11 10:31:10 +01:00
Pierre-Louis Mercereau
27bc23cbbc chore: exclude functions from workspace 2023-01-11 10:15:47 +01:00
Szilárd Dóró
61120a137a feat(dashboard): add redeployment support to overview 2023-01-11 09:43:06 +01:00
Szilárd Dóró
faea8feb2e Merge branch 'main' into feat/retrigger-deployments 2023-01-11 08:56:05 +01:00
Szilárd Dóró
6450223558 Merge pull request #1498 from nhost/changeset-release/main
chore: update versions
2023-01-11 08:48:36 +01:00
Guido Curcio
a62a85a777 add comments to effects and router changes. 2023-01-11 02:07:15 -03:00
Guido Curcio
ae24f83953 fix changing application name redirect to 404, fix 404 flash when changing workspace name. 2023-01-11 01:33:28 -03:00
Guido Curcio
fc60d7a782 2023-01-10 19:14:00 -03:00
Guido Curcio
6be8a998df 2023-01-10 19:13:09 -03:00
Guido Curcio
ea091f6251 2023-01-10 19:11:02 -03:00
Guido Curcio
8175c052f7 2023-01-10 18:50:45 -03:00
github-actions[bot]
e6605a6ed0 chore: update versions 2023-01-10 16:43:20 +00:00
Szilárd Dóró
1cba0e6492 Merge pull request #1497 from nhost/fix/database-ui-hasura-metadata
fix(dashboard): don't break the table creation process
2023-01-10 17:41:38 +01:00
Szilárd Dóró
179c90fcdb fix(dashboard): update inline snapshot 2023-01-10 17:13:29 +01:00
Szilárd Dóró
552e31a4f0 feat(dashboard): initial redeploy button code 2023-01-10 17:12:14 +01:00
Szilárd Dóró
85f0f943a1 chore(dashboard): add changeset 2023-01-10 16:04:15 +01:00
Szilárd Dóró
c4c23fde31 fix(dashboard): don't break table creation
don't break table creation when referencing a table that is not in the `public` schema
2023-01-10 15:39:05 +01:00
renovate[bot]
6d9df237a8 chore(deps): update vitest monorepo to ^0.27.0 2023-01-09 13:34:33 +00:00
Johan Eliasson
c4561cae38 redirect 2023-01-07 10:20:29 +01:00
Guido Curcio
e44352abbd typo in CreateWorkspaceFormProps Save -> Create 2023-01-05 23:48:35 -03:00
Guido Curcio
f9289f3c32 Merge branch 'fix(dashboard)/workspace-creation-redirection-delete' of https://github.com/nhost/nhost into fix(dashboard)/workspace-creation-redirection-delete 2023-01-05 23:47:01 -03:00
Guido Curcio
8ff06e5637 disable create workspace button if error on input. 2023-01-05 23:45:32 -03:00
Guido Curcio
49e4633bca handle when workspace name is already taken. 2023-01-05 23:41:23 -03:00
Guido Curcio
7ae7a7206c Update dashboard/src/components/home/CreateWorkspaceForm/CreateWorkspaceForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:31:02 -03:00
Guido Curcio
43d7e7babf Update dashboard/src/components/workspace/WorkspaceSection.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:30:54 -03:00
Guido Curcio
463a51ce7c Update dashboard/src/components/home/CreateWorkspaceForm/CreateWorkspaceForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:30:47 -03:00
Guido Curcio
86e9d9d47f Update dashboard/src/components/home/CreateWorkspaceForm/CreateWorkspaceForm.tsx
Co-authored-by: Szilárd Dóró <doroszilard@icloud.com>
2023-01-05 23:30:42 -03:00
Guido Curcio
f99b72cd7c useUserData instead of nhost.auth.getUser() 2023-01-05 23:29:42 -03:00
Guido Curcio
0dc2f3ff29 remove unused file 2023-01-05 23:24:11 -03:00
Guido Curcio
dbd3ded515 add patch changeset for dashboard. 2023-01-04 17:41:20 -03:00
Guido Curcio
5399fac211 remove AddWorkspace.tsx file and imports. 2023-01-04 17:39:01 -03:00
Guido Curcio
52e3127a34 fix(dashboard): workspaces creation, new form, correct redirects. 2023-01-04 17:34:35 -03:00
Johan Eliasson
599387934c update 2022-12-31 09:37:45 +01:00
Johan Eliasson
04cea41111 removed package 2022-12-31 08:20:02 +01:00
Johan Eliasson
dc3723306d updated lock file 2022-12-30 11:38:48 +01:00
Johan Eliasson
d7fa572ab6 Merge branch 'main' into example-updates 2022-12-30 11:38:08 +01:00
renovate[bot]
a529b654bc chore(deps): update dependency @types/react-dom to v18.0.10 2022-12-26 17:31:33 +00:00
Johan Eliasson
c21118257f updated lock file 2022-12-25 21:43:27 +01:00
Johan Eliasson
4712b7ff68 updated readme files 2022-12-25 21:40:13 +01:00
Johan Eliasson
4f305a8985 update 2022-12-25 21:33:12 +01:00
Johan Eliasson
cd7d133ba3 updated README 2022-12-25 21:32:30 +01:00
Johan Eliasson
2927a9ac31 move 2022-12-25 21:31:00 +01:00
Johan Eliasson
695eaa77ca update 2022-12-25 21:29:18 +01:00
Johan Eliasson
a29d21e194 react apollo updated 2022-12-25 15:21:52 +01:00
Johan Eliasson
cd20bd4ef2 urql fixes + apollo metadata updates 2022-12-25 14:57:11 +01:00
290 changed files with 12505 additions and 4847 deletions

View File

@@ -2,20 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"linked": [
[
"@nhost/nextjs",
"@nhost/react",
"@nhost/vue",
"@nhost/nhost-js",
"@nhost/hasura-auth-js",
"@nhost/hasura-storage-js"
],
[
"@nhost/react-apollo",
"@nhost/apollo"
]
],
"linked": [],
"access": "restricted",
"baseBranch": "main",
"updateInternalDependencies": "patch",

View File

@@ -1,5 +1,63 @@
# @nhost/dashboard
## 0.10.0
### Minor Changes
- ed4c7801: chore(dashboard): remove Functions section
## 0.9.10
### Patch Changes
- 4e2f8ccd: fix(dashboard): don't break Auth page in local mode
## 0.9.9
### Patch Changes
- 31abbe5f: fix(dashboard): enable toggle when settings are filled in
## 0.9.8
### Patch Changes
- 5bdd31ad: chore(dashboard): list fewer images per page on the Storage page
- 5121851c: fix(dashboard): don't throw validation error for valid permission rules
## 0.9.7
### Patch Changes
- c126b20d: fix(dashboard): correct redeployment button
## 0.9.6
### Patch Changes
- 36c3519c: feat(dashboard): retrigger deployments
## 0.9.5
### Patch Changes
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
- Updated dependencies [200e9f77]
- @nhost/nextjs@1.13.2
- @nhost/react-apollo@4.13.2
## 0.9.4
### Patch Changes
- dbd3ded5: fix(dashboard): workspaces creation, new form, correct redirects.
## 0.9.3
### Patch Changes
- 85f0f943: fix(dashboard): don't break the table creation process
## 0.9.2
### Patch Changes

View File

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

View File

@@ -1,6 +1,6 @@
# Nhost Dashboard
This is the Nhost Dashboard, a web application that allows you to manage your Nhost project.
This is the Nhost Dashboard, a web application that allows you to manage your Nhost projects.
To get started, you need to have an Nhost project. If you don't have one, you can [create a project here](https://app.nhost.io).
```bash

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "0.9.2",
"version": "0.10.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -8,7 +8,7 @@
"build": "next build --no-lint",
"analyze": "ANALYZE=true pnpm build --no-lint",
"start": "next start",
"lint": "next lint --max-warnings 3",
"lint": "next lint --max-warnings 2",
"test": "vitest",
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
"nhost:dev": "nhost dev -d",
@@ -105,14 +105,14 @@
"@types/node": "^16.11.7",
"@types/pluralize": "^0.0.29",
"@types/react": "18.0.25",
"@types/react-dom": "18.0.9",
"@types/react-dom": "18.0.10",
"@types/react-table": "^7.7.12",
"@types/testing-library__jest-dom": "^5.14.5",
"@types/validator": "^13.7.10",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
"@vitejs/plugin-react": "^3.0.0",
"@vitest/coverage-c8": "^0.26.0",
"@vitest/coverage-c8": "^0.27.0",
"autoprefixer": "^10.4.13",
"babel-loader": "^8.3.0",
"babel-plugin-transform-remove-console": "^6.9.4",
@@ -143,7 +143,7 @@
"typescript": "^4.8.4",
"vite": "^4.0.2",
"vite-tsconfig-paths": "^4.0.3",
"vitest": "^0.26.2",
"vitest": "^0.27.0",
"webpack": "^5.75.0"
},
"browserslist": {

View File

@@ -1,21 +1,14 @@
import type { DeploymentRowFragment } from '@/generated/graphql';
import { useGetDeploymentsSubSubscription } from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Avatar } from '@/ui/Avatar';
import DelayedLoading from '@/ui/DelayedLoading';
import Status, { StatusEnum } from '@/ui/Status';
import type { DeploymentStatus } from '@/ui/StatusCircle';
import { StatusCircle } from '@/ui/StatusCircle';
import { getLastLiveDeployment } from '@/utils/helpers';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
import {
differenceInSeconds,
formatDistanceToNowStrict,
parseISO,
} from 'date-fns';
useGetDeploymentsSubSubscription,
useLatestLiveDeploymentSubSubscription,
useScheduledOrPendingDeploymentsSubSubscription,
} from '@/generated/graphql';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import List from '@/ui/v2/List';
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/solid';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
type AppDeploymentsProps = {
appId: string;
@@ -66,146 +59,8 @@ function NextPrevPageLink(props: NextPrevPageLinkProps) {
);
}
type AppDeploymentDurationProps = {
startedAt: string;
endedAt: string;
};
export function AppDeploymentDuration({
startedAt,
endedAt,
}: AppDeploymentDurationProps) {
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
let interval: NodeJS.Timeout;
if (!endedAt) {
interval = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
}
return () => {
clearInterval(interval);
};
}, [endedAt]);
const totalDurationInSeconds = differenceInSeconds(
endedAt ? parseISO(endedAt) : currentTime,
parseISO(startedAt),
);
if (totalDurationInSeconds > 1200) {
return <div>20+m</div>;
}
const durationMins = Math.floor(totalDurationInSeconds / 60);
const durationSecs = totalDurationInSeconds % 60;
return (
<div
style={{
fontVariantNumeric: 'tabular-nums',
}}
className="self-center font-display text-sm+ text-greyscaleDark"
>
{durationMins}m {durationSecs}s
</div>
);
}
type AppDeploymentRowProps = {
deployment: DeploymentRowFragment;
isDeploymentLive: boolean;
};
export function AppDeploymentRow({
deployment,
isDeploymentLive,
}: AppDeploymentRowProps) {
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const { commitMessage } = deployment;
return (
<div className="flex flex-row items-center px-2 py-4">
<div className="mr-2 flex items-center justify-center">
<Avatar
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="h-8 w-8"
/>
</div>
<div className="mx-4 w-full">
<Link
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
passHref
>
<a
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
>
<div className="max-w-md truncate text-sm+ font-normal text-greyscaleDark">
{commitMessage?.trim() || (
<span className="pr-1 font-normal italic">
No commit message
</span>
)}
</div>
<div className="text-sm+ text-greyscaleGrey">
{formatDistanceToNowStrict(
parseISO(deployment.deploymentStartedAt),
{
addSuffix: true,
},
)}
</div>
</a>
</Link>
</div>
<div className="flex flex-row">
{isDeploymentLive && (
<div className="flex self-center align-middle">
<Status status={StatusEnum.Live}>Live</Status>
</div>
)}
<div className="w-28 self-center text-right font-mono text-sm- font-medium">
<a
className="font-mono font-medium text-greyscaleDark"
target="_blank"
rel="noreferrer"
href={`https://github.com/${currentApplication.githubRepository?.fullName}/commit/${deployment.commitSHA}`}
>
{deployment.commitSHA.substring(0, 7)}
</a>
</div>
<div className="mx-4 w-28 text-right">
<AppDeploymentDuration
startedAt={deployment.deploymentStartedAt}
endedAt={deployment.deploymentEndedAt}
/>
</div>
<div className="mx-3 self-center">
<StatusCircle
status={deployment.deploymentStatus as DeploymentStatus}
/>
</div>
<div className="self-center">
<Link
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
passHref
>
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
</Link>
</div>
</div>
</div>
);
}
export default function AppDeployments(props: AppDeploymentsProps) {
const { appId } = props;
const [idOfLiveDeployment, setIdOfLiveDeployment] = useState('');
const router = useRouter();
@@ -216,36 +71,59 @@ export default function AppDeployments(props: AppDeploymentsProps) {
const limit = 10;
const offset = (page - 1) * limit;
// @TODO: Should query for all deployments, then subscribe to new ones.
const { data, loading, error } = useGetDeploymentsSubSubscription({
variables: {
id: appId,
limit,
offset,
},
const {
data: deploymentPageData,
loading: deploymentPageLoading,
error,
} = useGetDeploymentsSubSubscription({
variables: { id: appId, limit, offset },
});
useEffect(() => {
if (!data) {
return;
}
const { data: latestDeploymentData, loading: latestDeploymentLoading } =
useGetDeploymentsSubSubscription({
variables: { id: appId, limit: 1, offset: 0 },
});
if (page === 1) {
setIdOfLiveDeployment(getLastLiveDeployment(data.deployments));
}
}, [data, idOfLiveDeployment, loading, page]);
const {
data: latestLiveDeploymentData,
loading: latestLiveDeploymentLoading,
} = useLatestLiveDeploymentSubSubscription({ variables: { appId } });
const {
data: scheduledOrPendingDeploymentsData,
loading: scheduledOrPendingDeploymentsLoading,
} = useScheduledOrPendingDeploymentsSubSubscription({ variables: { appId } });
const loading =
deploymentPageLoading ||
scheduledOrPendingDeploymentsLoading ||
latestDeploymentLoading ||
latestLiveDeploymentLoading;
if (loading) {
return <DelayedLoading delay={500} className="mt-12" />;
return (
<ActivityIndicator
delay={500}
className="mt-12"
label="Loading deployments..."
/>
);
}
if (error) {
throw error;
}
const nrOfDeployments = data.deployments.length;
const { deployments } = deploymentPageData || {};
const { deployments: scheduledOrPendingDeployments } =
scheduledOrPendingDeploymentsData || {};
const latestDeployment = latestDeploymentData?.deployments[0];
const latestLiveDeployment = latestLiveDeploymentData?.deployments[0];
const nrOfDeployments = deployments?.length || 0;
const nextAllowed = !(nrOfDeployments < limit);
const liveDeploymentId = latestLiveDeployment?.id || '';
return (
<div className="mt-6">
@@ -253,15 +131,17 @@ export default function AppDeployments(props: AppDeploymentsProps) {
<p className="text-sm text-greyscaleGrey">No deployments yet.</p>
) : (
<div>
<div className="mt-3 divide-y-1 border-t border-b">
{data.deployments.map((deployment) => (
<AppDeploymentRow
deployment={deployment}
<List className="mt-3 divide-y-1 border-t border-b">
{deployments.map((deployment) => (
<DeploymentListItem
key={deployment.id}
isDeploymentLive={idOfLiveDeployment === deployment.id}
deployment={deployment}
isLive={liveDeploymentId === deployment.id}
showRedeploy={latestDeployment.id === deployment.id}
disableRedeploy={scheduledOrPendingDeployments.length > 0}
/>
))}
</div>
</List>
<div className="mt-8 flex w-full justify-center">
<div className="flex items-center">
<NextPrevPageLink

View File

@@ -1,119 +0,0 @@
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { inputErrorMessages } from '@/utils/getErrorMessage';
import { slugifyString } from '@/utils/helpers';
import { triggerToast } from '@/utils/toast';
import { useUpdateWorkspaceMutation } from '@/utils/__generated__/graphql';
import router from 'next/router';
import type { ChangeEvent } from 'react';
import React, { useState } from 'react';
type ChangeWorkspaceNameProps = {
close: VoidFunction;
};
export default function ChangeWorkspaceName({
close,
}: ChangeWorkspaceNameProps) {
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
const [newWorkspaceName, setNewWorkspaceName] = useState(
currentWorkspace.name,
);
const [workspaceError, setWorkspaceError] = useState<string>('');
const [updateWorkspace, { loading: mutationLoading, error: mutationError }] =
useUpdateWorkspaceMutation({
refetchQueries: [],
});
function handleChange(event: ChangeEvent<HTMLInputElement>) {
inputErrorMessages(
event.target.value,
setNewWorkspaceName,
setWorkspaceError,
'Workspace',
);
}
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
const name = newWorkspaceName;
const slug = slugifyString(name);
if (slug.length < 4 || slug.length > 32) {
setWorkspaceError('Slug should be within 4 and 32 characters.');
return;
}
try {
await updateWorkspace({
variables: {
id: currentWorkspace.id,
workspace: {
name,
slug,
},
},
});
close();
triggerToast('Workspace name changed');
} catch (error) {
await discordAnnounce(
`Error trying to remove workspace: ${currentWorkspace.id} - ${error.message}`,
);
}
await router.push(slug);
}
return (
<div className="w-modal px-6 py-6 text-left">
<div className="flex flex-col">
<Text variant="h3" component="h2">
Change Workspace Name
</Text>
<form onSubmit={handleSubmit}>
<div className="mt-4 grid grid-flow-row gap-2">
<Input
id="workspaceName"
label="New Workspace Name"
onChange={handleChange}
value={newWorkspaceName}
placeholder="New workspace name"
fullWidth
autoFocus
autoComplete="off"
helperText={`https://app.nhost.io/${slugifyString(
newWorkspaceName || '',
)}`}
/>
{workspaceError && <Alert severity="error">{workspaceError}</Alert>}
{mutationError && (
<Alert severity="error">{mutationError.toString()}</Alert>
)}
</div>
<div className="mt-6 grid grid-flow-row gap-2">
<Button
type="submit"
disabled={mutationLoading || !!workspaceError}
>
Save Changes
</Button>
<Button variant="outlined" color="secondary" onClick={close}>
Close
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -1,5 +0,0 @@
export type FunctionLog = {
name: string;
language: string;
logs: { date: string; message: string; createdAt: string }[];
};

View File

@@ -1,30 +0,0 @@
import { Text } from '@/ui/Text';
import { ChevronRightIcon } from '@heroicons/react/solid';
import { formatDistance } from 'date-fns';
export interface FunctionLogDataEntryProps {
time: string;
nav: string;
}
export function FunctionLogDataEntry({ time, nav }: FunctionLogDataEntryProps) {
return (
<a href={`#${nav}`}>
<div className="flex cursor-pointer flex-row place-content-between border-t py-3">
<Text
color="greyscaleDark"
variant="body"
className="flex font-medium"
size="tiny"
>
{formatDistance(new Date(time), new Date(), {
addSuffix: true,
})}
</Text>
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center text-greyscaleDark" />
</div>
</a>
);
}
export default FunctionLogDataEntry;

View File

@@ -1,52 +0,0 @@
import { Text } from '@/ui/Text';
import { FunctionLogDataEntry } from './FunctionLogDataEntry';
export interface FunctionLogHistoryProps {
logs?: Log[];
}
type Log = {
createdAt: string;
date: any;
message: string;
};
export function FunctionLogHistory({ logs }: FunctionLogHistoryProps) {
return (
<div className=" mx-auto max-w-6xl pt-10">
<div className="flex flex-row place-content-between">
<div className="flex">
<Text size="large" className="font-medium" color="greyscaleDark">
Log History
</Text>
</div>
</div>
<div className="mt-5 flex flex-col">
<div className="flex flex-row">
<Text className="font-semibold" size="normal" color="greyscaleDark">
Time
</Text>
</div>
<div className="flex flex-col">
{logs ? (
<div>
{logs.slice(0, 4).map((log: Log) => (
<FunctionLogDataEntry
time={log.createdAt}
nav={`#-${log.date}`}
key={`${log.date}-${log.message.slice(66)}`}
/>
))}
</div>
) : (
<div className="pt-1 pl-0.5 font-mono text-xs text-greyscaleDark">
No log history.
</div>
)}
</div>
</div>
</div>
);
}
export default FunctionLogHistory;

View File

@@ -1,89 +0,0 @@
import { normalizeToIndividualFunctionsWithLogs } from '@/components/applications/functions/normalizeToIndividualFunctionsWithLogs';
import terminalTheme from '@/data/terminalTheme';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useGetFunctionLogQuery } from '@/utils/__generated__/graphql';
import { useEffect, useState } from 'react';
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
import { FunctionLogHistory } from './FunctionLogHistory';
SyntaxHighlighter.registerLanguage('json', json);
export function FunctionsLogsTerminalPage({ functionName }: any) {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [normalizedFunctionData, setNormalizedFunctionData] = useState(null);
const { data, startPolling } = useGetFunctionLogQuery({
variables: {
subdomain: currentApplication.subdomain,
functionPaths: [functionName?.split('/').slice(1, 3).join('/')],
},
});
useEffect(() => {
startPolling(3000);
}, [startPolling]);
useEffect(() => {
if (!data || data.getFunctionLogs.length === 0) {
return;
}
setNormalizedFunctionData(
normalizeToIndividualFunctionsWithLogs(data.getFunctionLogs)[0],
);
}, [data]);
if (
!data ||
data.getFunctionLogs.length === 0 ||
!normalizedFunctionData ||
normalizedFunctionData.logs.length === 0
) {
return (
<div className="w-full rounded-lg text-white">
<div className="h-terminal overflow-auto rounded-lg bg-log px-4 py-4 font-mono shadow-sm">
<div className="font-mono text-xs text-grey">
There are no stored logs yet. Try calling your function for logs to
appear.
</div>
</div>
<FunctionLogHistory />
</div>
);
}
return (
<div className="w-full rounded-lg text-white">
<div className="h-terminal overflow-auto rounded-lg bg-log px-4 py-4 font-mono shadow-sm">
{normalizedFunctionData.logs.map((log) => (
<div
key={`${log.date}-${log.message.slice(66)}`}
className=" flex text-sm"
>
<div id={`#-${log.date}`}>
<pre className="inline">
<span className="mr-4 text-greyscaleGrey">{log.date}</span>{' '}
<span className="">
{' '}
<SyntaxHighlighter
style={terminalTheme}
customStyle={{
display: 'inline',
}}
className="inline-flex"
language="json"
>
{log.message}
</SyntaxHighlighter>
</span>
</pre>
</div>
</div>
))}
</div>
<FunctionLogHistory logs={normalizedFunctionData.logs} />
</div>
);
}
export default FunctionsLogsTerminalPage;

View File

@@ -1,5 +0,0 @@
export type FunctionResponseLog = {
functionPath: string;
createdAt: string;
message: string;
};

View File

@@ -1,46 +0,0 @@
import { Button } from '@/ui/Button';
import Loading from '@/ui/Loading';
import { Text } from '@/ui/Text';
import Image from 'next/image';
export function FunctionsNotDeployed() {
return (
<div className="mx-auto mt-12 max-w-2xl text-center">
<div className="mx-auto flex w-centImage flex-col text-center">
<Image
src="/terminal-text.svg"
alt="Terminal with a green dot"
width={72}
height={72}
/>
</div>
<Text className="mt-4 font-medium" size="large" color="dark">
Functions Logs
</Text>
<Text size="normal" color="greyscaleDark" className="mt-1 transform">
Once you deploy a function, you can view the logs here.
</Text>
<div className="mt-1.5 flex text-center">
<Button
Component="a"
transparent
color="blue"
className="mx-auto cursor-pointer font-medium"
href="https://docs.nhost.io/platform/serverless-functions"
target="_blank"
rel="noreferrer"
>
Read more
</Button>
</div>
<div className="mt-24 flex flex-col text-center">
<Loading />
<Text size="normal" color="greyscaleDark" className="mt-1 transform">
Awaiting new requests
</Text>
</div>
</div>
);
}
export default FunctionsNotDeployed;

View File

@@ -1,79 +0,0 @@
export type FinalFunction = {
folder: string;
funcs: Func[];
nestedLevel: number;
parentFolder?: string;
};
export type Func = {
name: string;
id: string;
lang: string;
functionName: string;
route?: string;
path?: string;
createdAt?: string;
updatedAt?: string;
createdWithCommitSha?: string;
formattedCreatedAt?: string;
formattedUpdatedAt?: string;
};
export const normalizeFunctionMetadata = (functions): FinalFunction[] => {
const finalFunctions: FinalFunction[] = [
{ folder: 'functions', funcs: [], nestedLevel: 0 },
];
const topLevelFunctionsFolder = finalFunctions[0].funcs;
functions.forEach((func) => {
const nestedLevel = func.path?.split('/').length;
const newFuncToAdd = {
...func,
name: func.path?.split('/')[nestedLevel - 1],
lang: func.path?.split('.')[1],
// formattedCreatedAt: `${format(
// parseISO(func.createdAt),
// 'yyyy-MM-dd HH:mm:ss',
// )}`,
// formattedUpdatedAt: `${formatDistanceToNowStrict(
// parseISO(func.updatedAt),
// {
// addSuffix: true,
// },
// )}`,
};
if (nestedLevel === 2) {
topLevelFunctionsFolder.push(newFuncToAdd);
} else if (nestedLevel > 2) {
const nameOfTheFolder = func.path?.split('/')[nestedLevel - 2];
const nameOfParentFolder = func.path?.split('/')[nestedLevel - 3];
const checkForFolderExistence = finalFunctions.find(
(functionFolder) => functionFolder.folder === nameOfTheFolder,
);
if (!checkForFolderExistence) {
finalFunctions.push({
folder: nameOfTheFolder,
funcs: [newFuncToAdd],
nestedLevel: nestedLevel - 2,
parentFolder: nameOfParentFolder,
});
} else {
checkForFolderExistence.funcs.push(newFuncToAdd);
}
}
});
// Sort folders by putting the subfolder next to their parent folder, even though they share the same place in the array
// except for the nestedLevel prop. A future change to this would be to make folders have subfolders, which is easier
// understand, but would require a change in the UI.
// @TODO: Change to have elements have subfolders inside the object?
finalFunctions.sort((a, b) => {
if (a.folder === b.parentFolder) {
return -1;
}
return 1;
});
return finalFunctions;
};

View File

@@ -1,39 +0,0 @@
import { format, parseISO } from 'date-fns';
import type { FunctionLog } from './FunctionLog';
import type { FunctionResponseLog } from './FunctionResponseLog';
export const normalizeToIndividualFunctionsWithLogs = (
functionLogs: FunctionResponseLog[],
) => {
const arrayOfFunctions: FunctionLog[] = [];
const sortedFunctions = [...functionLogs].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
sortedFunctions.forEach((functionLog) => {
const funcName = functionLog.functionPath;
const logMessage = {
createdAt: functionLog.createdAt,
date: `${format(parseISO(functionLog.createdAt), 'yyyy-MM-dd HH:mm:ss')}`,
message: functionLog.message,
};
const newFunc = {
name: funcName,
language: functionLog.functionPath.split('.')[1],
logs: [logMessage],
};
// If the function is already in the array of functions to log, just add the new log message to the existing object...
if (arrayOfFunctions.some((obj) => obj.name === funcName)) {
const index = arrayOfFunctions.findIndex((obj) => obj.name === funcName);
const currentFunction = arrayOfFunctions[index];
currentFunction.logs.push(logMessage);
} else {
// If the function is not in the array of functions, add it with the log message to it.
arrayOfFunctions.push(newFunc);
}
});
return arrayOfFunctions;
};
export default normalizeToIndividualFunctionsWithLogs;

View File

@@ -34,13 +34,13 @@ export function Repo({ repo, setSelectedRepoId }: RepoProps) {
const [updateApp, { loading, error }] = useUpdateAppMutation({
refetchQueries: [
refetchGetAppByWorkspaceAndNameQuery({
workspace: currentWorkspace.slug,
slug: currentApplication.slug,
workspace: currentWorkspace?.slug,
slug: currentApplication?.slug,
}),
],
});
const { githubRepository } = currentApplication;
const { githubRepository } = currentApplication || {};
const isThisRepositoryAlreadyConnected =
githubRepository?.fullName && githubRepository.fullName === repo.fullName;

View File

@@ -52,9 +52,9 @@ function ControlledAutocomplete(
return (
<Autocomplete
inputValue={typeof field.value === 'string' ? field.value : undefined}
{...props}
{...field}
inputValue={typeof field.value === 'string' ? field.value : undefined}
ref={mergeRefs([field.ref, ref])}
onChange={(event, options, reason, details) => {
setValue?.(controllerProps?.name || name, options, {

View File

@@ -6,6 +6,7 @@ import { createContext } from 'react';
* Available dialog types.
*/
export type DialogType =
| 'EDIT_WORKSPACE_NAME'
| 'CREATE_RECORD'
| 'CREATE_COLUMN'
| 'EDIT_COLUMN'

View File

@@ -1,6 +1,7 @@
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
@@ -366,6 +367,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
<RetryableErrorBoundary
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
>
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
<EditWorkspaceNameForm {...sharedDialogProps} />
)}
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
<CreateForeignKeyForm {...sharedDialogProps} />
)}

View File

@@ -118,6 +118,7 @@ export default function BaseColumnForm({
variant="inline"
className="col-span-8 py-3"
autoFocus
autoComplete="off"
/>
<ControlledAutocomplete
@@ -272,6 +273,7 @@ export default function BaseColumnForm({
error={Boolean(errors.comment)}
variant="inline"
className="col-span-8 py-3"
autoComplete="off"
/>
</section>
</div>

View File

@@ -88,6 +88,7 @@ function NameInput() {
error={Boolean(errors.name)}
variant="inline"
className="col-span-8 py-3"
autoComplete="off"
autoFocus
/>
);

View File

@@ -70,6 +70,7 @@ function NameInput({ index }: FieldArrayInputProps) {
}
},
})}
autoComplete="off"
aria-label="Name"
placeholder="Enter name"
hideEmptyHelperText

View File

@@ -13,6 +13,7 @@ import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text';
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
import { useTheme } from '@mui/material';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import PermissionSettingsSection from './PermissionSettingsSection';
@@ -41,6 +42,7 @@ export default function ColumnPresetsSection({
table,
disabled,
}: ColumnPresetSectionProps) {
const theme = useTheme();
const {
data: tableData,
status: tableStatus,
@@ -131,7 +133,12 @@ export default function ColumnPresetsSection({
freeSolo
fullWidth
disableClearable={false}
clearIcon={<XIcon />}
clearIcon={
<XIcon
className="w-4 h-4 mt-px"
sx={{ color: theme.palette.text.primary }}
/>
}
autoSelect
autoHighlight={false}
error={Boolean(

View File

@@ -4,7 +4,17 @@ import * as Yup from 'yup';
const ruleSchema = Yup.object().shape({
column: Yup.string().nullable().required('Please select a column.'),
operator: Yup.string().nullable().required('Please select an operator.'),
value: Yup.string().nullable().required('Please enter a value.'),
value: Yup.mixed()
.test(
'isArray',
'Please enter a valid value.',
(value) =>
typeof value === 'string' ||
(Array.isArray(value) &&
value.every((item) => typeof item === 'string')),
)
.nullable()
.required('Please enter a value.'),
});
const ruleGroupSchema = Yup.object().shape({

View File

@@ -0,0 +1,60 @@
import { differenceInSeconds, parseISO } from 'date-fns';
import { useEffect, useState } from 'react';
export interface AppDeploymentDurationProps {
/**
* Start date of the deployment.
*/
startedAt: string;
/**
* End date of the deployment.
*/
endedAt?: string;
}
export default function AppDeploymentDuration({
startedAt,
endedAt,
}: AppDeploymentDurationProps) {
const [currentTime, setCurrentTime] = useState(new Date());
useEffect(() => {
let interval: NodeJS.Timeout;
if (!endedAt) {
interval = setInterval(() => {
setCurrentTime(new Date());
}, 1000);
}
return () => {
clearInterval(interval);
};
}, [endedAt]);
const totalDurationInSeconds = differenceInSeconds(
endedAt ? parseISO(endedAt) : currentTime,
parseISO(startedAt),
);
if (totalDurationInSeconds > 1200) {
return <div>20+m</div>;
}
const durationMins = Math.floor(totalDurationInSeconds / 60);
const durationSecs = totalDurationInSeconds % 60;
return (
<div
style={{ fontVariantNumeric: 'tabular-nums' }}
className="self-center font-display text-sm+ text-greyscaleDark"
>
{Number.isNaN(durationMins) || Number.isNaN(durationSecs) ? (
<span>0m 0s</span>
) : (
<span>
{durationMins}m {durationSecs}s
</span>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,161 @@
import NavLink from '@/components/common/NavLink';
import AppDeploymentDuration from '@/components/deployments/AppDeploymentDuration';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Avatar } from '@/ui/Avatar';
import Status, { StatusEnum } from '@/ui/Status';
import type { DeploymentStatus } from '@/ui/StatusCircle';
import { StatusCircle } from '@/ui/StatusCircle';
import Button from '@/ui/v2/Button';
import ArrowCounterclockwiseIcon from '@/ui/v2/icons/ArrowCounterclockwiseIcon';
import ChevronRightIcon from '@/ui/v2/icons/ChevronRightIcon';
import { ListItem } from '@/ui/v2/ListItem';
import Tooltip from '@/ui/v2/Tooltip';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
import { useInsertDeploymentMutation } from '@/utils/__generated__/graphql';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface DeploymentListItemProps {
/**
* Deployment data.
*/
deployment: DeploymentRowFragment;
/**
* Determines whether or not the deployment is live.
*/
isLive?: boolean;
/**
* Determines whether or not the redeploy button should be shown for the
* deployment.
*/
showRedeploy?: boolean;
/**
* Determines whether or not the redeploy button is disabled.
*/
disableRedeploy?: boolean;
}
export default function DeploymentListItem({
deployment,
isLive,
showRedeploy,
disableRedeploy,
}: DeploymentListItemProps) {
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const relativeDateOfDeployment = deployment.deploymentStartedAt
? formatDistanceToNowStrict(parseISO(deployment.deploymentStartedAt), {
addSuffix: true,
})
: '';
const [insertDeployment, { loading }] = useInsertDeploymentMutation();
const { commitMessage } = deployment;
return (
<ListItem.Root>
<ListItem.Button
className="grid grid-flow-col items-center justify-between gap-2 px-2 py-2"
component={NavLink}
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
>
<div className="flex cursor-pointer flex-row items-center justify-center space-x-2 self-center">
<ListItem.Avatar>
<Avatar
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="h-8 w-8 shrink-0"
/>
</ListItem.Avatar>
<ListItem.Text
primary={
commitMessage?.trim() || (
<span className="truncate pr-1 font-normal italic">
No commit message
</span>
)
}
secondary={relativeDateOfDeployment}
/>
</div>
<div className="grid grid-flow-col gap-2 items-center">
{showRedeploy && (
<Tooltip
title="Deployments cannot be re-triggered when a deployment is in progress."
hasDisabledChildren={disableRedeploy || loading}
disableHoverListener={!disableRedeploy}
>
<Button
disabled={disableRedeploy || loading}
size="small"
color="secondary"
variant="outlined"
onClick={async (event) => {
event.stopPropagation();
event.preventDefault();
const insertDeploymentPromise = insertDeployment({
variables: {
object: {
appId: currentApplication?.id,
commitMessage: deployment.commitMessage,
commitSHA: deployment.commitSHA,
commitUserAvatarUrl: deployment.commitUserAvatarUrl,
commitUserName: deployment.commitUserName,
deploymentStatus: 'SCHEDULED',
},
},
});
await toast.promise(
insertDeploymentPromise,
{
loading: 'Scheduling deployment...',
success: 'Deployment has been scheduled successfully.',
error: 'An error occurred when scheduling deployment.',
},
toastStyleProps,
);
}}
startIcon={
<ArrowCounterclockwiseIcon className={twMerge('w-4 h-4')} />
}
className="rounded-full py-1 px-2 text-xs"
>
Redeploy
</Button>
</Tooltip>
)}
{isLive && (
<div className="w-12 flex justify-end">
<Status status={StatusEnum.Live}>Live</Status>
</div>
)}
<div className="w-16 text-right font-mono text-sm- font-medium">
{deployment.commitSHA.substring(0, 7)}
</div>
<div className="w-[80px] text-right font-mono text-sm- font-medium">
<AppDeploymentDuration
startedAt={deployment.deploymentStartedAt}
endedAt={deployment.deploymentEndedAt}
/>
</div>
<StatusCircle
status={deployment.deploymentStatus as DeploymentStatus}
/>
<ChevronRightIcon className="h-4 w-4" />
</div>
</ListItem.Button>
</ListItem.Root>
);
}

View File

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

View File

@@ -37,7 +37,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
parseInt(router.query.page as string, 10) - 1 || 0,
);
const [sortBy, setSortBy] = useState<SortingRule<StoredFile>[]>();
const limit = 25;
const limit = 10;
const emptyStateMessage = searchString
? 'No search results found.'
: 'No files are uploaded yet.';

View File

@@ -0,0 +1,239 @@
import Form from '@/components/common/Form';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import {
refetchGetOneUserQuery,
useInsertWorkspaceMutation,
useUpdateWorkspaceMutation,
} from '@/utils/__generated__/graphql';
import { slugifyString } from '@/utils/helpers';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import * as Yup from 'yup';
export interface EditWorkspaceNameFormProps {
/**
* The current workspace name if this is an edit operation.
*/
currentWorkspaceName?: string;
/**
* The current workspace name id if this is an edit operation.
*/
currentWorkspaceId?: string;
/**
* Determines whether the form is disabled.
*/
disabled?: boolean;
/**
* Submit button text.
*
* @default 'Create'
*/
submitButtonText?: string;
/**
* Function to be called when the form is submitted.
*/
onSubmit?: () => void;
/**
* Function to be called when the operation is cancelled.
*/
onCancel?: VoidFunction;
}
export interface EditWorkspaceNameFormValues {
/**
* New workspace name.
*/
newWorkspaceName: string;
}
const validationSchema = Yup.object().shape({
newWorkspaceName: Yup.string()
.required('Workspace name is required.')
.min(4, 'The new Workspace name must be at least 4 characters.')
.max(32, "The new Workspace name can't be longer than 32 characters.")
.test(
'canBeSlugified',
`This field should be at least 4 characters and can't be longer than 32 characters.`,
(value) => {
const slug = slugifyString(value);
if (slug.length < 4 || slug.length > 32) {
return false;
}
return true;
},
),
});
export default function EditWorkspaceName({
disabled,
onSubmit,
onCancel,
currentWorkspaceName,
currentWorkspaceId,
submitButtonText = 'Create',
}: EditWorkspaceNameFormProps) {
const currentUser = useUserData();
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const [updateWorkspaceName] = useUpdateWorkspaceMutation({
refetchQueries: [
refetchGetOneUserQuery({
userId: currentUser.id,
}),
],
awaitRefetchQueries: true,
ignoreResults: true,
});
const router = useRouter();
const form = useForm<EditWorkspaceNameFormValues>({
defaultValues: {
newWorkspaceName: currentWorkspaceName || '',
},
resolver: yupResolver(validationSchema),
});
const {
register,
formState: { dirtyFields, isSubmitting, errors },
} = form;
const isDirty = Object.keys(dirtyFields).length > 0;
async function handleSubmit({
newWorkspaceName,
}: EditWorkspaceNameFormValues) {
const slug = slugifyString(newWorkspaceName);
try {
if (currentWorkspaceId) {
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `mutating: true`.
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
// i.e. redirecting to 404 if there's no workspace/project with that slug.
await router.replace({
pathname: router.pathname,
query: { ...router.query, updating: true },
});
await toast.promise(
updateWorkspaceName({
variables: {
id: currentWorkspaceId,
workspace: {
name: newWorkspaceName,
slug,
},
},
}),
{
loading: 'Updating workspace name...',
success: 'Workspace name has been updated successfully.',
error: 'An error occurred while updating the workspace name.',
},
toastStyleProps,
);
} else {
await toast.promise(
insertWorkspace({
variables: {
workspace: {
name: newWorkspaceName,
companyName: newWorkspaceName,
email: currentUser.email,
slug,
workspaceMembers: {
data: [
{
userId: currentUser.id,
type: 'owner',
},
],
},
},
},
}),
{
loading: 'Creating new workspace...',
success: 'The new workspace has been created successfully.',
error: 'An error occurred while creating the new workspace.',
},
toastStyleProps,
);
}
} catch (error) {
if (error.message?.includes('duplicate key value')) {
form.setError(
'newWorkspaceName',
{
type: 'manual',
message: 'This workspace name is already taken.',
},
{
shouldFocus: false,
},
);
}
return;
}
await client.refetchQueries({
include: ['getOneUser'],
});
await router.push(slug);
onSubmit?.();
}
return (
<FormProvider {...form}>
<Form
onSubmit={handleSubmit}
className="flex flex-col content-between flex-auto pt-2 pb-6 overflow-hidden"
>
<div className="flex-auto px-6 overflow-y-auto">
<Input
{...register('newWorkspaceName')}
error={Boolean(errors.newWorkspaceName?.message)}
label="Name"
helperText={errors.newWorkspaceName?.message}
autoFocus={!disabled}
disabled={disabled}
fullWidth
hideEmptyHelperText
placeholder='e.g. "My Workspace"'
/>
</div>
<div className="grid flex-shrink-0 grid-flow-row gap-2 px-6 pt-4">
{!disabled && (
<Button
loading={isSubmitting}
disabled={
isSubmitting || Boolean(errors.newWorkspaceName?.message)
}
type="submit"
>
{currentWorkspaceName ? 'Save' : submitButtonText}
</Button>
)}
<Button
variant="outlined"
color="secondary"
onClick={onCancel}
tabIndex={isDirty ? -1 : 0}
autoFocus={disabled}
>
{disabled ? 'Close' : 'Cancel'}
</Button>
</div>
</Form>
</FormProvider>
);
}

View File

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

View File

@@ -4,11 +4,8 @@ import { InviteAnnounce } from '@/components/home/InviteAnnounce';
import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
import BaseLayout from '@/components/layout/BaseLayout';
import Container from '@/components/layout/Container';
import AddWorkspace from '@/components/workspace/AddWorkspace';
import { useUI } from '@/context/UIContext';
import useIsHealthy from '@/hooks/common/useIsHealthy';
import useIsPlatform from '@/hooks/common/useIsPlatform';
import { Modal } from '@/ui';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Link from '@/ui/v2/Link';
import Text from '@/ui/v2/Text';
@@ -39,7 +36,6 @@ export default function AuthenticatedLayout({
}: AuthenticatedLayoutProps) {
const router = useRouter();
const isPlatform = useIsPlatform();
const { newWorkspace, closeSection } = useUI();
const { isAuthenticated, isLoading } = useAuthenticationStatus();
const isHealthy = useIsHealthy();
@@ -85,7 +81,7 @@ export default function AuthenticatedLayout({
<BaseLayout {...props}>
<Header className="flex max-h-[59px] flex-auto" />
<Container className="my-12 grid max-w-md grid-flow-row justify-center gap-2 text-center">
<Container className="grid justify-center max-w-md grid-flow-row gap-2 my-12 text-center">
<div className="mx-auto">
<Image
src="/terminal-text.svg"
@@ -123,13 +119,7 @@ export default function AuthenticatedLayout({
}
return (
<BaseLayout className="flex h-full flex-col" {...props}>
<Modal
showModal={newWorkspace}
close={closeSection}
Component={AddWorkspace}
/>
<BaseLayout className="flex flex-col h-full" {...props}>
<Header className="flex max-h-[59px] flex-auto" />
<InviteAnnounce />

View File

@@ -1,25 +1,21 @@
import { AppDeploymentDuration } from '@/components/applications/AppDeployments';
import { EditRepositorySettings } from '@/components/applications/github/EditRepositorySettings';
import useGitHubModal from '@/components/applications/github/useGitHubModal';
import { useDialog } from '@/components/common/DialogProvider';
import NavLink from '@/components/common/NavLink';
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
import GithubIcon from '@/components/icons/GithubIcon';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Avatar } from '@/ui/Avatar';
import Status, { StatusEnum } from '@/ui/Status';
import type { DeploymentStatus } from '@/ui/StatusCircle';
import { StatusCircle } from '@/ui/StatusCircle';
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Button from '@/ui/v2/Button';
import RocketIcon from '@/ui/v2/icons/RocketIcon';
import type { ListItemRootProps } from '@/ui/v2/ListItem';
import { ListItem } from '@/ui/v2/ListItem';
import List from '@/ui/v2/List';
import Text from '@/ui/v2/Text';
import { getLastLiveDeployment } from '@/utils/helpers';
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
import { useGetDeploymentsSubSubscription } from '@/utils/__generated__/graphql';
import {
useGetDeploymentsSubSubscription,
useScheduledOrPendingDeploymentsSubSubscription,
} from '@/utils/__generated__/graphql';
import { ChevronRightIcon } from '@heroicons/react/solid';
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
import { twMerge } from 'tailwind-merge';
function OverviewDeploymentsTopBar() {
@@ -54,92 +50,6 @@ function OverviewDeploymentsTopBar() {
);
}
interface OverviewDeployProps extends ListItemRootProps {
/**
* Deployment metadata to display.
*/
deployment: DeploymentRowFragment;
/**
* Determines to show a status badge showing the live status of a deployment reflecting the latest state of the application.
*/
isDeploymentLive: boolean;
}
function OverviewDeployment({
deployment,
isDeploymentLive,
className,
}: OverviewDeployProps) {
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const relativeDateOfDeployment = formatDistanceToNowStrict(
parseISO(deployment.deploymentStartedAt),
{
addSuffix: true,
},
);
const { commitMessage } = deployment;
return (
<ListItem.Root className={twMerge('grid grid-flow-row', className)}>
<ListItem.Button
className="grid grid-flow-col items-center justify-between gap-2 px-2 py-2"
component={NavLink}
href={`/${currentWorkspace.slug}/${currentApplication.slug}/deployments/${deployment.id}`}
>
<div className="flex cursor-pointer flex-row items-center justify-center space-x-2 self-center">
<div>
<Avatar
name={deployment.commitUserName}
avatarUrl={deployment.commitUserAvatarUrl}
className="h-8 w-8"
/>
</div>
<div className="grid grid-flow-row truncate text-sm+ font-medium">
<Text className="inline cursor-pointer truncate font-medium leading-snug text-greyscaleDark">
{commitMessage?.trim() || (
<span className="truncate pr-1 font-normal italic">
No commit message
</span>
)}
</Text>
<Text className="text-sm font-normal leading-[1.375rem] text-greyscaleGrey">
{relativeDateOfDeployment}
</Text>
</div>
</div>
<div className="grid grid-flow-col items-center self-center">
{isDeploymentLive && (
<div className="flex self-center align-middle">
<Status status={StatusEnum.Live}>Live</Status>
</div>
)}
<div className="w-20 self-center text-right align-middle font-mono text-sm- font-medium">
{deployment.commitSHA.substring(0, 7)}
</div>
<div className="w-20 self-center text-right align-middle font-mono text-sm-">
<AppDeploymentDuration
startedAt={deployment.deploymentStartedAt}
endedAt={deployment.deploymentEndedAt}
/>
</div>
<div className="mx-3 self-center">
<StatusCircle
status={deployment.deploymentStatus as DeploymentStatus}
/>
</div>
<div className="self-center">
<ChevronRightIcon className="ml-2 h-4 w-4 cursor-pointer self-center" />
</div>
</div>
</ListItem.Button>
</ListItem.Root>
);
}
interface OverviewDeploymentsProps {
projectId: string;
githubRepository: { fullName: string };
@@ -159,9 +69,18 @@ function OverviewDeployments({
},
});
if (loading) {
const {
data: scheduledOrPendingDeploymentsData,
loading: scheduledOrPendingDeploymentsLoading,
} = useScheduledOrPendingDeploymentsSubSubscription({
variables: {
appId: projectId,
},
});
if (loading || scheduledOrPendingDeploymentsLoading) {
return (
<div style={{ height: '240px' }}>
<div className="h-60">
<ActivityIndicator label="Loading deployments..." />
</div>
);
@@ -218,22 +137,22 @@ function OverviewDeployments({
);
}
const getLastLiveDeploymentId = getLastLiveDeployment(deployments);
const liveDeploymentId = getLastLiveDeployment(deployments);
const { deployments: scheduledOrPendingDeployments } =
scheduledOrPendingDeploymentsData;
return (
<div className="rounded-x-lg flex flex-col divide-y-1 divide-gray-200 rounded-lg border border-veryLightGray">
{deployments.map((deployment) => {
const isDeploymentLive = deployment.id === getLastLiveDeploymentId;
return (
<OverviewDeployment
key={deployment.id}
deployment={deployment}
isDeploymentLive={isDeploymentLive}
/>
);
})}
</div>
<List className="rounded-x-lg flex flex-col divide-y-1 divide-gray-200 rounded-lg border border-veryLightGray">
{deployments.map((deployment, index) => (
<DeploymentListItem
key={deployment.id}
deployment={deployment}
isLive={deployment.id === liveDeploymentId}
showRedeploy={index === 0}
disableRedeploy={scheduledOrPendingDeployments.length > 0}
/>
))}
</List>
);
}

View File

@@ -5,12 +5,16 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { useState } from 'react';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface AllowedEmailSettingsFormValues {
/**
* Determines whether or not the allowed email settings are enabled.
*/
enabled: boolean;
/**
* Set of email that are allowed to be used for project's users authentication.
*/
@@ -25,7 +29,6 @@ export interface AllowedEmailSettingsFormValues {
export default function AllowedEmailDomainsSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [enabled, setEnabled] = useState(false);
const { data, loading, error } = useGetAppQuery({
variables: {
@@ -36,12 +39,30 @@ export default function AllowedEmailDomainsSettings() {
const form = useForm<AllowedEmailSettingsFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled:
Boolean(data?.app?.authAccessControlAllowedEmails) ||
Boolean(data?.app?.authAccessControlAllowedEmailDomains),
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
authAccessControlAllowedEmailDomains:
data?.app?.authAccessControlAllowedEmailDomains,
},
});
const { register, formState, setValue, watch } = form;
const enabled = watch('enabled');
const isDirty = Object.keys(formState.dirtyFields).length > 0;
useEffect(() => {
if (
!data.app?.authAccessControlAllowedEmails &&
!data.app?.authAccessControlAllowedEmailDomains
) {
return;
}
setValue('enabled', true, { shouldDirty: false });
}, [data.app, setValue]);
if (loading) {
return (
<ActivityIndicator
@@ -56,8 +77,6 @@ export default function AllowedEmailDomainsSettings() {
throw error;
}
const { register, formState } = form;
const handleAllowedEmailDomainsChange = async (
values: AllowedEmailSettingsFormValues,
) => {
@@ -65,7 +84,12 @@ export default function AllowedEmailDomainsSettings() {
variables: {
id: currentApplication.id,
app: {
...values,
authAccessControlAllowedEmails: values.enabled
? values.authAccessControlAllowedEmails
: '',
authAccessControlAllowedEmailDomains: values.enabled
? values.authAccessControlAllowedEmailDomains
: '',
},
},
});
@@ -89,13 +113,17 @@ export default function AllowedEmailDomainsSettings() {
<SettingsContainer
title="Allowed Emails and Domains"
description="Allow specific email addresses and domains to sign up."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isValid || !isDirty,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication"
enabled={enabled}
onEnabledChange={setEnabled}
onEnabledChange={(switchEnabled) =>
setValue('enabled', switchEnabled, { shouldDirty: true })
}
showSwitch
className={twMerge(
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',

View File

@@ -5,12 +5,16 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
import Input from '@/ui/v2/Input';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { useState } from 'react';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { toast } from 'react-hot-toast';
import { twMerge } from 'tailwind-merge';
export interface BlockedEmailFormValues {
/**
* Determines whether or not the blocked email settings are enabled.
*/
enabled: boolean;
/**
* Set of emails that are blocked from registering to the user's project.
*/
@@ -24,7 +28,6 @@ export interface BlockedEmailFormValues {
export default function BlockedEmailSettings() {
const { currentApplication } = useCurrentWorkspaceAndApplication();
const [updateApp] = useUpdateAppMutation();
const [enabled, setEnabled] = useState(false);
const { data, loading, error } = useGetAppQuery({
variables: {
@@ -35,12 +38,30 @@ export default function BlockedEmailSettings() {
const form = useForm<BlockedEmailFormValues>({
reValidateMode: 'onSubmit',
defaultValues: {
enabled:
Boolean(data?.app?.authAccessControlBlockedEmails) ||
Boolean(data?.app?.authAccessControlBlockedEmailDomains),
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
authAccessControlBlockedEmailDomains:
data?.app?.authAccessControlBlockedEmailDomains,
},
});
const { register, formState, setValue, watch } = form;
const enabled = watch('enabled');
const isDirty = Object.keys(formState.dirtyFields).length > 0;
useEffect(() => {
if (
!data.app?.authAccessControlBlockedEmails &&
!data.app?.authAccessControlBlockedEmailDomains
) {
return;
}
setValue('enabled', true, { shouldDirty: false });
}, [data.app, setValue]);
if (loading) {
return (
<ActivityIndicator
@@ -55,8 +76,6 @@ export default function BlockedEmailSettings() {
throw error;
}
const { register, formState } = form;
const handleAllowedEmailDomainsChange = async (
values: BlockedEmailFormValues,
) => {
@@ -64,7 +83,12 @@ export default function BlockedEmailSettings() {
variables: {
id: currentApplication.id,
app: {
...values,
authAccessControlBlockedEmails: values.enabled
? values.authAccessControlBlockedEmails
: '',
authAccessControlBlockedEmailDomains: values.enabled
? values.authAccessControlBlockedEmailDomains
: '',
},
},
});
@@ -88,13 +112,17 @@ export default function BlockedEmailSettings() {
<SettingsContainer
title="Blocked Emails and Domains"
description="Block specific email addresses and domains to sign up."
primaryActionButtonProps={{
disabled: !formState.isValid || !formState.isDirty,
loading: formState.isSubmitting,
slotProps={{
submitButton: {
disabled: !formState.isValid || !isDirty,
loading: formState.isSubmitting,
},
}}
docsLink="https://docs.nhost.io/platform/authentication"
enabled={enabled}
onEnabledChange={setEnabled}
onEnabledChange={(switchEnabled) =>
setValue('enabled', switchEnabled, { shouldDirty: true })
}
showSwitch
className={twMerge(
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',

View File

@@ -1,9 +1,11 @@
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
export type DeploymentStatus =
| 'DEPLOYING'
| 'DEPLOYED'
| 'FAILED'
| 'PENDING'
| 'SCHEDULED'
| undefined
| null;
@@ -12,32 +14,30 @@ type StatusCircleProps = {
className?: string;
};
export function StatusCircle(props: StatusCircleProps) {
const { status, className } = props;
export function StatusCircle({ status, className }: StatusCircleProps) {
const baseClasses = 'w-1.5 h-1.5 rounded-full';
if (!status) {
const classes = clsx(baseClasses, 'bg-gray-300', className);
return <div className={classes} />;
}
if (status === 'DEPLOYING') {
const classes = clsx(baseClasses, 'bg-yellow-300', className);
return <div className={classes} />;
if (status === 'DEPLOYING' || status === 'PENDING') {
return (
<div
className={twMerge(
baseClasses,
'bg-yellow-300 animate-pulse',
className,
)}
/>
);
}
if (status === 'DEPLOYED') {
const classes = clsx(baseClasses, 'bg-green-300', className);
return <div className={classes} />;
return <div className={twMerge(baseClasses, 'bg-green-300', className)} />;
}
if (status === 'FAILED') {
const classes = clsx(baseClasses, 'bg-red', className);
return <div className={classes} />;
return <div className={twMerge(baseClasses, 'bg-red', className)} />;
}
return null;
return <div className={twMerge(baseClasses, 'bg-gray-300', className)} />;
}
export default StatusCircle;

View File

@@ -0,0 +1,17 @@
import { styled } from '@mui/material';
import type { ListItemAvatarProps as MaterialListItemAvatarProps } from '@mui/material/ListItemAvatar';
import MaterialListItemAvatar from '@mui/material/ListItemAvatar';
export interface ListItemAvatarProps extends MaterialListItemAvatarProps {}
const StyledListItemAvatar = styled(MaterialListItemAvatar)({
minWidth: 0,
});
function ListItemAvatar({ children, ...props }: ListItemAvatarProps) {
return <StyledListItemAvatar {...props}>{children}</StyledListItemAvatar>;
}
ListItemAvatar.displayName = 'NhostListItemAvatar';
export default ListItemAvatar;

View File

@@ -1,8 +1,11 @@
import ListItemAvatar from './ListItemAvatar';
import ListItemButton from './ListItemButton';
import ListItemIcon from './ListItemIcon';
import ListItemRoot from './ListItemRoot';
import ListItemText from './ListItemText';
export * from './ListItemAvatar';
export { default as ListItemAvatar } from './ListItemAvatar';
export * from './ListItemButton';
export { default as ListItemButton } from './ListItemButton';
export * from './ListItemIcon';
@@ -13,6 +16,7 @@ export * from './ListItemText';
export { default as ListItemText } from './ListItemText';
export const ListItem = {
Avatar: ListItemAvatar,
Root: ListItemRoot,
Button: ListItemButton,
Icon: ListItemIcon,

View File

@@ -0,0 +1,34 @@
import type { IconProps } from '@/ui/v2/icons';
import SvgIcon from '@mui/material/SvgIcon';
function ArrowCounterclockwiseIcon(props: IconProps) {
return (
<SvgIcon
width="16"
height="16"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 16 16"
aria-label="A counterclockwise arrow"
{...props}
>
<path
d="M4.99 6.232h-3v-3"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<path
d="M4.11 11.89a5.5 5.5 0 1 0 0-7.78L1.99 6.233"
stroke="currentColor"
fill="none"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</SvgIcon>
);
}
ArrowCounterclockwiseIcon.displayName = 'NhostArrowCounterclockwiseIcon';
export default ArrowCounterclockwiseIcon;

View File

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

View File

@@ -67,8 +67,8 @@ export default function CreateUserForm({
} = form;
const baseAuthUrl = generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
currentApplication?.subdomain,
currentApplication?.region?.awsName,
'auth',
);

View File

@@ -8,18 +8,18 @@ import Button from '@/ui/v2/Button';
import Chip from '@/ui/v2/Chip';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import CopyIcon from '@/ui/v2/icons/CopyIcon';
import Input from '@/ui/v2/Input';
import InputLabel from '@/ui/v2/InputLabel';
import Option from '@/ui/v2/Option';
import Text from '@/ui/v2/Text';
import CopyIcon from '@/ui/v2/icons/CopyIcon';
import { copy } from '@/utils/copy';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import {
useGetRolesQuery,
useUpdateRemoteAppUserMutation,
} from '@/utils/__generated__/graphql';
import { copy } from '@/utils/copy';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import { yupResolver } from '@hookform/resolvers/yup';
import { Avatar } from '@mui/material';
import { format } from 'date-fns';
@@ -137,7 +137,7 @@ export default function EditUserForm({
}
const { data: dataRoles } = useGetRolesQuery({
variables: { id: currentApplication.id },
variables: { id: currentApplication?.id },
});
const allAvailableProjectRoles = getUserRoles(
@@ -206,11 +206,7 @@ export default function EditUserForm({
</div>
<div>
<Dropdown.Root>
<Dropdown.Trigger
autoFocus={false}
asChild
className="gap-2"
>
<Dropdown.Trigger autoFocus={false} asChild className="gap-2">
<Button variant="outlined" color="secondary">
Actions
</Button>

View File

@@ -7,12 +7,14 @@ import Chip from '@/ui/v2/Chip';
import Divider from '@/ui/v2/Divider';
import { Dropdown } from '@/ui/v2/Dropdown';
import IconButton from '@/ui/v2/IconButton';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import DotsHorizontalIcon from '@/ui/v2/icons/DotsHorizontalIcon';
import TrashIcon from '@/ui/v2/icons/TrashIcon';
import UserIcon from '@/ui/v2/icons/UserIcon';
import List from '@/ui/v2/List';
import { ListItem } from '@/ui/v2/ListItem';
import Text from '@/ui/v2/Text';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
import {
useDeleteRemoteAppUserRolesMutation,
@@ -21,9 +23,6 @@ import {
useRemoteAppDeleteUserMutation,
useUpdateRemoteAppUserMutation,
} from '@/utils/__generated__/graphql';
import getUserRoles from '@/utils/settings/getUserRoles';
import { toastStyleProps } from '@/utils/settings/settingsConstants';
import type { ApolloQueryResult } from '@apollo/client';
import { Avatar } from '@mui/material';
import { formatDistance } from 'date-fns';
@@ -77,7 +76,7 @@ export default function UsersBody({
* in the drawer form.
*/
const { data: dataRoles } = useGetRolesQuery({
variables: { id: currentApplication.id },
variables: { id: currentApplication?.id },
});
const allAvailableProjectRoles = useMemo(

View File

@@ -1,159 +0,0 @@
import { useUI } from '@/context/UIContext';
import { useInsertWorkspaceMutation } from '@/generated/graphql';
import { Alert } from '@/ui/Alert';
import Button from '@/ui/v2/Button';
import Input from '@/ui/v2/Input';
import Text from '@/ui/v2/Text';
import { getErrorMessage, inputErrorMessages } from '@/utils/getErrorMessage';
import { slugifyString } from '@/utils/helpers';
import { nhost } from '@/utils/nhost';
import { triggerToast } from '@/utils/toast';
import router from 'next/router';
import React, { useState } from 'react';
import slugify from 'slugify';
function AddNewWorkspaceForm({
closeSection: externalCloseSection,
}: {
closeSection: VoidFunction;
}) {
const [workspace, setWorkspace] = useState('');
const { closeSection } = useUI();
const [workspaceError, setWorkspaceError] = useState<string>('');
const [loadingAddWorkspace, setLoadingAddWorkspace] = useState(false);
const [insertWorkspace, { client }] = useInsertWorkspaceMutation();
const slug = slugify(workspace, { lower: true, strict: true });
const user = nhost.auth.getUser();
if (!user) {
return <div>No user..</div>;
}
const userId = user.id;
async function handleSubmit(e: React.SyntheticEvent<HTMLFormElement>) {
e.preventDefault();
setWorkspaceError('');
setLoadingAddWorkspace(true);
if (
!inputErrorMessages(
workspace,
setWorkspace,
setWorkspaceError,
'Workspace',
)
) {
return;
}
if (slug.length < 4 || slug.length > 32) {
setWorkspaceError('Slug should be within 4 and 32 characters.');
setLoadingAddWorkspace(false);
return;
}
const currentUser = nhost.auth.getUser();
if (!currentUser) {
triggerToast('User is not signed in');
setLoadingAddWorkspace(false);
return;
}
try {
await insertWorkspace({
variables: {
workspace: {
name: workspace,
companyName: workspace,
email: user.email,
slug,
workspaceMembers: {
data: [
{
userId,
type: 'owner',
},
],
},
},
},
});
await client.refetchQueries({ include: ['getOneUser'] });
router.push(`/${slug}`);
setLoadingAddWorkspace(false);
closeSection();
} catch (error: any) {
setWorkspaceError(getErrorMessage(error, 'workspace'));
setLoadingAddWorkspace(false);
}
}
return (
<form onSubmit={handleSubmit} className="grid grid-flow-row gap-4">
<Input
type="text"
placeholder="Your new workspace"
name="workspace"
id="workspace"
label="Workspace"
fullWidth
autoFocus
helperText={`https://app.nhost.io/${slugifyString(workspace)}`}
onChange={(event) => {
setWorkspace(event.target.value);
setWorkspaceError('');
}}
/>
{workspaceError && <Alert severity="error">{workspaceError}</Alert>}
<div className="grid grid-flow-col justify-between gap-2">
<Button
variant="outlined"
color="secondary"
onClick={(e) => {
e.preventDefault();
externalCloseSection();
}}
>
Cancel
</Button>
<Button
type="submit"
disabled={!!workspaceError}
loading={loadingAddWorkspace}
>
Create Workspace
</Button>
</div>
</form>
);
}
export default function AddWorkspace() {
const { closeSection } = useUI();
const user = nhost.auth.getUser();
if (!user) {
return <div>No user..</div>;
}
return (
<div className="grid w-modal grid-flow-row gap-2 px-6 py-6 text-left">
<div className="grid w-full grid-flow-row gap-1">
<Text variant="h3" component="h2">
New Workspace
</Text>
<Text variant="subtitle2">
Invite team members to workspaces to work collaboratively.
</Text>
</div>
<AddNewWorkspaceForm closeSection={closeSection} />
</div>
);
}

View File

@@ -1,14 +1,16 @@
import { Avatar } from '@/ui/Avatar';
import Text from '@/ui/v2/Text';
import { nhost } from '@/utils/nhost';
import { useGetWorkspacesQuery } from '@/utils/__generated__/graphql';
import { nhost } from '@/utils/nhost';
import Image from 'next/image';
import Link from 'next/link';
import { useEffect } from 'react';
export default function SidebarWorkspaces() {
const user = nhost.auth.getUser();
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery();
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery({
fetchPolicy: 'cache-and-network',
});
useEffect(() => {
startPolling(1000);
@@ -28,7 +30,7 @@ export default function SidebarWorkspaces() {
<div className="mt-3 mb-4 space-y-2">
<div className="flex flex-row">
<svg
className="ml-1 h-4 w-4 animate-spin self-center text-dark"
className="self-center w-4 h-4 ml-1 animate-spin text-dark"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
@@ -47,7 +49,7 @@ export default function SidebarWorkspaces() {
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<Text size="tiny" className="ml-2 self-center" color="greyscaleGrey">
<Text size="tiny" className="self-center ml-2" color="greyscaleGrey">
Creating first workspace...
</Text>
</div>
@@ -66,12 +68,12 @@ export default function SidebarWorkspaces() {
>
{name === 'Default Workspace' && creatorUserId === user.id ? (
<Avatar
className="h-8 w-8 self-center rounded-full"
className="self-center w-8 h-8 rounded-full"
name={user?.displayName}
avatarUrl={user?.avatarUrl}
/>
) : (
<div className="inline-block h-8 w-8 overflow-hidden rounded-lg">
<div className="inline-block w-8 h-8 overflow-hidden rounded-lg">
<Image
src="/logos/new.svg"
alt="Nhost Logo"

View File

@@ -1,4 +1,4 @@
import ChangeWorkspaceName from '@/components/applications/ChangeWorkspaceName';
import { useDialog } from '@/components/common/DialogProvider';
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
import { useUI } from '@/context/UIContext';
import { useGetWorkspace } from '@/hooks/use-GetWorkspace';
@@ -13,7 +13,6 @@ import { copy } from '@/utils/copy';
import { nhost } from '@/utils/nhost';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useState } from 'react';
export default function WorkspaceHeader() {
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
@@ -21,14 +20,14 @@ export default function WorkspaceHeader() {
query: { workspaceSlug },
} = useRouter();
const [changeWorkspaceNameModal, setChangeWorkspaceNameModal] =
useState(false);
const {
openDeleteWorkspaceModal,
closeDeleteWorkspaceModal,
deleteWorkspaceModal,
} = useUI();
const { openDialog } = useDialog();
const { data } = useGetWorkspace(workspaceSlug);
const workspace = data?.workspaces[0];
@@ -45,11 +44,6 @@ export default function WorkspaceHeader() {
return (
<div className="mx-auto flex max-w-3xl flex-col">
<Modal
showModal={changeWorkspaceNameModal}
close={() => setChangeWorkspaceNameModal(!changeWorkspaceNameModal)}
Component={ChangeWorkspaceName}
/>
<Modal
showModal={deleteWorkspaceModal}
close={closeDeleteWorkspaceModal}
@@ -112,9 +106,23 @@ export default function WorkspaceHeader() {
>
<Dropdown.Item
className="py-2"
onClick={() =>
setChangeWorkspaceNameModal(!changeWorkspaceNameModal)
}
onClick={() => {
openDialog('EDIT_WORKSPACE_NAME', {
title: (
<span className="grid grid-flow-row">
<span>Change Workspace Name</span>
<Text variant="subtitle1" component="span">
Changing the workspace name will also affect the URL
of the workspace.
</Text>
</span>
),
payload: {
currentWorkspaceName: currentWorkspace.name,
currentWorkspaceId: currentWorkspace.id,
},
});
}}
>
Change workspace name
</Dropdown.Item>

View File

@@ -1,11 +1,12 @@
import { useDialog } from '@/components/common/DialogProvider';
import { SidebarTitle } from '@/components/home/SidebarTitle';
import { useUI } from '@/context/UIContext';
import Button from '@/ui/v2/Button';
import Text from '@/ui/v2/Text';
import PlusCircleIcon from '@/ui/v2/icons/PlusCircleIcon';
import SidebarWorkspaces from './SidebarWorkspaces';
export function WorkspaceSection() {
const { openSection } = useUI();
const { openDialog } = useDialog();
return (
<>
@@ -15,7 +16,19 @@ export function WorkspaceSection() {
<Button
variant="borderless"
color="secondary"
onClick={openSection}
onClick={() => {
openDialog('EDIT_WORKSPACE_NAME', {
title: (
<span className="grid grid-flow-row">
<span>New Workspace</span>
<Text variant="subtitle1" component="span">
Invite team members to workspaces to work collaboratively.
</Text>
</span>
),
});
}}
startIcon={<PlusCircleIcon />}
>
New Workspace

View File

@@ -1,114 +0,0 @@
const terminalTheme = {
hljs: {
display: 'block',
background: '#F4F7F9',
color: '#21324B',
},
'hljs-tag': {
color: '#9C73DF',
},
'hljs-keyword': {
color: '#9C73DF',
fontWeight: 'bold',
},
'hljs-selector-tag': {
color: '#9C73DF',
fontWeight: 'bold',
},
'hljs-literal': {
color: '#9C73DF',
fontWeight: 'bold',
},
'hljs-strong': {
color: '#9C73DF',
},
'hljs-name': {
color: '#9C73DF',
},
'hljs-code': {
color: '#66d9ef',
},
'hljs-class .hljs-title': {
color: 'red',
},
'hljs-attribute': {
color: '#bf79db',
},
'hljs-symbol': {
color: '#bf79db',
},
'hljs-regexp': {
color: '#bf79db',
},
'hljs-link': {
color: '#bf79db',
},
'hljs-string': {
color: '#B7A590',
},
'hljs-bullet': {
color: '#B7A590',
},
'hljs-subst': {
color: '#B7A590',
},
'hljs-title': {
color: '#B7A590',
fontWeight: 'bold',
},
'hljs-section': {
color: '#B7A590',
fontWeight: 'bold',
},
'hljs-emphasis': {
color: '#3ECF8E',
},
'hljs-type': {
color: '#3ECF8E',
fontWeight: 'bold',
},
'hljs-built_in': {
color: '#3ECF8E',
},
'hljs-builtin-name': {
color: '#3ECF8E',
},
'hljs-selector-attr': {
color: '#3ECF8E',
},
'hljs-selector-pseudo': {
color: '#3ECF8E',
},
'hljs-addition': {
color: '#3ECF8E',
},
'hljs-variable': {
color: '#3ECF8E',
},
'hljs-template-tag': {
color: '#3ECF8E',
},
'hljs-template-variable': {
color: '#3ECF8E',
},
'hljs-comment': {
color: '#21324B',
},
'hljs-quote': {
color: '#75715e',
},
'hljs-deletion': {
color: '#75715e',
},
'hljs-meta': {
color: '#75715e',
},
'hljs-doctag': {
fontWeight: 'bold',
},
'hljs-selector-id': {
fontWeight: 'bold',
},
};
export default terminalTheme;

View File

@@ -20,6 +20,34 @@ query getDeployments($id: uuid!, $limit: Int!, $offset: Int!) {
}
}
subscription ScheduledOrPendingDeploymentsSub($appId: uuid!) {
deployments(
where: {
deploymentStatus: { _in: ["PENDING", "SCHEDULED"] }
appId: { _eq: $appId }
}
) {
...DeploymentRow
}
}
subscription LatestLiveDeploymentSub($appId: uuid!) {
deployments(
where: { deploymentStatus: { _eq: "DEPLOYED" }, appId: { _eq: $appId } }
order_by: { deploymentEndedAt: desc }
limit: 1
offset: 0
) {
...DeploymentRow
}
}
mutation InsertDeployment($object: deployments_insert_input!) {
insertDeployment(object: $object) {
...DeploymentRow
}
}
subscription getDeploymentsSub($id: uuid!, $limit: Int!, $offset: Int!) {
deployments(
where: { appId: { _eq: $id } }

View File

@@ -6,7 +6,6 @@ import FileTextIcon from '@/ui/v2/icons/FileTextIcon';
import GraphQLIcon from '@/ui/v2/icons/GraphQLIcon';
import HasuraIcon from '@/ui/v2/icons/HasuraIcon';
import HomeIcon from '@/ui/v2/icons/HomeIcon';
import LambdaIcon from '@/ui/v2/icons/LambdaIcon';
import RocketIcon from '@/ui/v2/icons/RocketIcon';
import StorageIcon from '@/ui/v2/icons/StorageIcon';
import UserIcon from '@/ui/v2/icons/UserIcon';
@@ -54,13 +53,6 @@ export default function useProjectRoutes() {
const isPlatform = useIsPlatform();
const nhostRoutes: ProjectRoute[] = [
{
relativePath: '/functions',
exact: false,
label: 'Functions',
icon: <LambdaIcon />,
disabled: !isPlatform,
},
{
relativePath: '/deployments',
exact: false,

View File

@@ -237,7 +237,10 @@ test('should drop existing relationships and prepare a new one-to-many relations
"cascade": false,
"relationship": "books",
"source": "default",
"table": "authors",
"table": {
"name": "authors",
"schema": "public",
},
},
"type": "pg_drop_relationship",
}

View File

@@ -152,7 +152,12 @@ export default async function prepareTrackForeignKeyRelationsMetadata({
type: 'pg_drop_relationship',
args: {
source: dataSource,
table: foreignKeyRelation.referencedTable,
table: foreignKeyRelation.referencedSchema
? {
name: foreignKeyRelation.referencedTable,
schema: foreignKeyRelation.referencedSchema,
}
: foreignKeyRelation.referencedTable,
relationship: oneToManyRelationshipName,
cascade: false,
},

View File

@@ -184,7 +184,7 @@ export default function prepareUpdateTableQuery({
);
return [
...args,
...updatedArgs,
...prepareUpdateForeignKeyConstraintQuery({
...baseVariables,
originalForeignKeyRelation,

View File

@@ -70,7 +70,7 @@ export default function useFiles({
currentApplication.subdomain,
currentApplication.region.awsName,
'storage',
)}/${file.id}`;
)}/files/${file.id}`;
const fetchParams = new URLSearchParams();

View File

@@ -10,7 +10,7 @@ export default function useNotFoundRedirect() {
useCurrentWorkspaceAndApplication();
const router = useRouter();
const {
query: { workspaceSlug, appSlug },
query: { workspaceSlug, appSlug, updating },
} = useRouter();
const notIn404Already = router.pathname !== '/404';
@@ -25,6 +25,15 @@ export default function useNotFoundRedirect() {
const inSettingsDatabasePage = router.pathname.includes('/settings/database');
useEffect(() => {
// This code is checking if the URL has a query of the form `?updating=true`
// If it does (`updating` is true) this useEffect will immediately exit without executing
// any further statements (e.g. the page will show a loader until `updating` is false).
// This is to prevent the user from being redirected to the 404 page while we are updating
// either the workspace slug or application slug.
if (updating) {
return;
}
if (noResolvedWorkspace && notIn404Already) {
router.push('/404');
}
@@ -37,6 +46,7 @@ export default function useNotFoundRedirect() {
router.push('/404');
}
}, [
updating,
currentApplication,
currentWorkspace,
noResolvedApplication,

View File

@@ -1,4 +1,4 @@
import { AppDeploymentDuration } from '@/components/applications/AppDeployments';
import AppDeploymentDuration from '@/components/deployments/AppDeploymentDuration';
import Container from '@/components/layout/Container';
import ProjectLayout from '@/components/layout/ProjectLayout';
import { useDeploymentSubSubscription } from '@/generated/graphql';
@@ -8,6 +8,7 @@ import DelayedLoading from '@/ui/DelayedLoading';
import type { DeploymentStatus } from '@/ui/StatusCircle';
import { StatusCircle } from '@/ui/StatusCircle';
import { Text } from '@/ui/Text';
import Link from '@/ui/v2/Link';
import { format, formatDistanceToNowStrict, parseISO } from 'date-fns';
import { useRouter } from 'next/router';
import type { ReactElement } from 'react';
@@ -50,6 +51,12 @@ export default function DeploymentDetailsPage() {
);
}
const relativeDateOfDeployment = deployment.deploymentStartedAt
? formatDistanceToNowStrict(parseISO(deployment.deploymentStartedAt), {
addSuffix: true,
})
: '';
return (
<Container>
<div className="flex justify-between">
@@ -104,24 +111,20 @@ export default function DeploymentDetailsPage() {
{deployment.commitMessage}
</div>
<div className="text-sm+ text-greyscaleGrey">
{formatDistanceToNowStrict(
parseISO(deployment.deploymentStartedAt),
{
addSuffix: true,
},
)}
{relativeDateOfDeployment}
</div>
</div>
</div>
<div className=" flex items-center">
<a
className="self-center font-mono text-sm- font-medium text-greyscaleDark"
<Link
className="self-center font-mono text-sm- font-medium"
target="_blank"
rel="noreferrer"
href={`https://github.com/${currentApplication.githubRepository?.fullName}/commit/${deployment.commitSHA}`}
underline="hover"
>
{deployment.commitSHA.substring(0, 7)}
</a>
</Link>
<div className="w-20 text-right">
<AppDeploymentDuration
@@ -133,6 +136,10 @@ export default function DeploymentDetailsPage() {
</div>
<div>
<div className="rounded-lg bg-verydark p-4 text-sm- text-white">
{deployment.deploymentLogs.length === 0 && (
<span className="font-mono">No message.</span>
)}
{deployment.deploymentLogs.map((log) => (
<div key={log.id} className="flex font-mono">
<div className=" mr-2 flex-shrink-0">

View File

@@ -1,269 +0,0 @@
import ConnectGithubModal from '@/components/applications/ConnectGithubModal';
import { FunctionsNotDeployed } from '@/components/applications/functions/FunctionsNotDeployed';
import { normalizeFunctionMetadata } from '@/components/applications/functions/normalizeFunctionMetadata';
import { EditRepositorySettings } from '@/components/applications/github/EditRepositorySettings';
import useGitHubModal from '@/components/applications/github/useGitHubModal';
import Folder from '@/components/icons/Folder';
import Container from '@/components/layout/Container';
import ProjectLayout from '@/components/layout/ProjectLayout';
import { useWorkspaceContext } from '@/context/workspace-context';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { Button } from '@/ui/Button';
import DelayedLoading from '@/ui/DelayedLoading';
import { Modal } from '@/ui/Modal';
import Status, { StatusEnum } from '@/ui/Status';
import { Text } from '@/ui/Text';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { useGetAppFunctionsMetadataQuery } from '@/utils/__generated__/graphql';
import { ChevronRightIcon } from '@heroicons/react/solid';
import clsx from 'clsx';
import Image from 'next/image';
import Link from 'next/link';
import type { ReactElement } from 'react';
import { useEffect, useState } from 'react';
function FunctionsNoRepo() {
const [githubModal, setGithubModal] = useState(false);
const [githubRepoModal, setGithubRepoModal] = useState(false);
const { openGitHubModal } = useGitHubModal();
return (
<>
<Modal showModal={githubModal} close={() => setGithubModal(!githubModal)}>
<ConnectGithubModal close={() => setGithubModal(false)} />
</Modal>
<Modal
showModal={githubRepoModal}
close={() => setGithubRepoModal(!githubRepoModal)}
>
<EditRepositorySettings
openConnectGithubModal={() => setGithubModal(true)}
close={() => setGithubRepoModal(false)}
handleSelectAnotherRepository={openGitHubModal}
/>
</Modal>
<div className="mx-auto flex w-centImage flex-col text-center">
<Image
src="/assets/githubRepo.svg"
width={72}
height={72}
alt="GitHub Logo"
/>
</div>
<Text className="mt-4 font-medium" size="large" color="dark">
Function Logs
</Text>
<div className="flex">
<div className="mx-auto flex flex-row self-center text-center">
<Text size="normal" color="greyscaleDark" className="mt-1">
To deploy serverless functions, you need to connect your project to
version control.
</Text>
</div>
</div>
<div className="mt-3 flex text-center">
<Button
transparent
color="blue"
className="mx-auto font-medium"
onClick={() => setGithubModal(true)}
>
Connect your Project to GitHub
</Button>
</div>
</>
);
}
export default function FunctionsPage() {
const { currentWorkspace, currentApplication } =
useCurrentWorkspaceAndApplication();
const { workspaceContext } = useWorkspaceContext();
const { data, loading, error } = useGetAppFunctionsMetadataQuery({
variables: { id: currentApplication?.id },
});
const [normalizedFunctions, setNormalizedFunctions] = useState(null);
useEffect(() => {
if (!data) {
return;
}
if (data.app.metadataFunctions) {
setNormalizedFunctions(
normalizeFunctionMetadata(data.app.metadataFunctions),
);
}
}, [data]);
if (!workspaceContext.repository) {
return (
<Container className="mt-12 max-w-3xl text-center antialiased">
<FunctionsNoRepo />
</Container>
);
}
if (loading) {
return (
<Container>
<DelayedLoading delay={500} className="mt-12" />
</Container>
);
}
if (!data || normalizedFunctions === null) {
return (
<Container>
<FunctionsNotDeployed />
</Container>
);
}
if (error) {
return <Container>Error</Container>;
}
return (
<Container>
<div className="mt-2">
{normalizedFunctions?.map((folder) => (
<div key={folder.folder}>
<div
className={clsx(
'flex flex-row pt-8 pb-2 align-middle',
folder.nestedLevel < 2 && 'ml-6',
folder.nestedLevel >= 2 && 'ml-12',
)}
>
<div className={clsx('flex w-full')}>
{folder.nestedLevel > 0 && (
<Folder className="self-center align-middle text-greyscaleGrey" />
)}
<Text
color="greyscaleDark"
variant="body"
className={clsx(
'font-medium',
folder.nestedLevel > 0 && 'ml-2',
)}
size="tiny"
>
{folder.folder}/
</Text>
</div>
{folder.nestedLevel === 0 ? (
<div className="flex w-full flex-row">
<div className="flex w-52">
<Text
color="greyscaleDark"
variant="body"
className="font-medium"
size="tiny"
>
Created At
</Text>
</div>
<div className="flex w-16 self-end">
<Text
color="greyscaleDark"
variant="body"
className="font-medium"
size="tiny"
>
Status
</Text>
</div>
</div>
) : null}
</div>
<div
className={clsx(
'border-t py-1',
folder.nestedLevel < 2 && 'ml-6',
folder.nestedLevel >= 2 && 'ml-12',
)}
>
{folder.funcs.map((func) => (
<Link
key={func.id}
href={{
pathname:
'/[workspaceSlug]/[appSlug]/functions/[functionId]',
query: {
workspaceSlug: currentWorkspace.slug,
appSlug: currentApplication.slug,
functionId: func.functionName,
},
}}
passHref
>
<a
href="[workspaceSlug]/[appSlug]/functions/[functionId]"
className={clsx(
'flex cursor-pointer flex-row border-b py-2.5',
folder.nestedLevel && 'ml-0',
)}
>
<div className="flex w-full flex-row items-center">
<Image
src={`/assets/functions/${func.lang}.svg`}
alt={`Logo of ${func.lang}`}
width={16}
height={16}
/>
<Text
color="greyscaleDark"
variant="body"
className="pl-2 font-medium"
size="small"
>
{func.name}
</Text>
</div>
<div className="flex w-full flex-row">
<div className={clsx('flex w-52 self-center')}>
<Text
color="greyscaleDark"
variant="body"
className=""
size="tiny"
>
{func.formattedCreatedAt || '-'}
</Text>
</div>
<div className="flex w-16 self-center">
<Status status={StatusEnum.Live}>Live</Status>
<ChevronRightIcon className="middl ml-2 h-4 w-4 cursor-pointer self-center" />
</div>
</div>
</a>
</Link>
))}
</div>
</div>
))}
</div>
<div className="mx-auto mt-10 max-w-6xl">
<div className="text-center">
<Text size="tiny" color="greyscaleDark" className="font-medium">
Base URL for function endpoints is{' '}
{generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'functions',
)}
</Text>
</div>
</div>
</Container>
);
}
FunctionsPage.getLayout = function getLayout(page: ReactElement) {
return <ProjectLayout>{page}</ProjectLayout>;
};

View File

@@ -1,142 +0,0 @@
import { FunctionsLogsTerminalPage } from '@/components/applications/functions/FunctionLogsTerminalFromPage';
import type { Func } from '@/components/applications/functions/normalizeFunctionMetadata';
import { normalizeFunctionMetadata } from '@/components/applications/functions/normalizeFunctionMetadata';
import { LoadingScreen } from '@/components/common/LoadingScreen';
import Help from '@/components/icons/Help';
import Container from '@/components/layout/Container';
import ProjectLayout from '@/components/layout/ProjectLayout';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import { useGetAllUserWorkspacesAndApplications } from '@/hooks/useGetAllUserWorkspacesAndApplications';
import { Text } from '@/ui/Text';
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
import { yieldFunction } from '@/utils/helpers';
import { useGetAppFunctionsMetadataQuery } from '@/utils/__generated__/graphql';
import Image from 'next/image';
import { useRouter } from 'next/router';
import type { ReactElement } from 'react';
import { useEffect, useState } from 'react';
export default function FunctionDetailsPage() {
const { currentApplication, currentWorkspace } =
useCurrentWorkspaceAndApplication();
useGetAllUserWorkspacesAndApplications(false);
const { data, loading, error } = useGetAppFunctionsMetadataQuery({
variables: { id: currentApplication?.id },
});
const [currentFunction, setCurrentFunction] = useState<Func | null>(null);
const router = useRouter();
// currentFunction will be null until we get data back from remote and we set it to be the function we're looking for.
useEffect(() => {
if (!data) {
return;
}
const appFunctions = normalizeFunctionMetadata(data?.app.metadataFunctions);
setCurrentFunction(yieldFunction(appFunctions, router));
}, [data, router]);
if (!currentApplication || !currentWorkspace || loading) {
return <LoadingScreen />;
}
if (error) {
throw new Error(
error.message ||
'An unexpected error has ocurred. Please try again later.',
);
}
if (!currentFunction) {
return (
<Container>
<h1 className="text-4xl font-semibold text-greyscaleDark">Not found</h1>
<p className="text-sm text-greyscaleGrey">
This function does not exist.
</p>
</Container>
);
}
return (
<>
<Container>
<div className="flex place-content-between">
<div className="flex flex-row items-center py-1">
<Image
src={`/assets/functions/${
currentFunction.name.split('.')[1]
}.svg`}
alt={`Logo of ${currentFunction.name.split('.')[1]}`}
width={40}
height={40}
/>
<div className="flex flex-col">
<Text
color="greyscaleDark"
variant="body"
className="ml-2 font-medium"
size="big"
>
{currentFunction.name}
</Text>
<a
className="ml-2 text-xs font-medium text-greyscaleGrey"
href={`${generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'functions',
)}${currentFunction?.route}`}
target="_blank"
rel="noreferrer"
>
{`${generateAppServiceUrl(
currentApplication.subdomain,
currentApplication.region.awsName,
'functions',
)}${currentFunction?.route}`}
</a>
</div>
</div>
</div>
</Container>
<Container className="pt-10">
<div className="flex flex-row place-content-between">
<div className="flex">
<Text size="large" className="font-medium" color="greyscaleDark">
Log
</Text>
</div>
<div className="flex">
<Text
size="tiny"
className="self-center font-medium"
color="greyscaleDark"
>
Awaiting new requests
</Text>
<a
href="https://docs.nhost.io/platform/serverless-functions"
target="_blank"
rel="noreferrer"
>
<Help className="h-7 w-7" />
</a>
</div>
</div>
<div className="mt-5">
<FunctionsLogsTerminalPage functionName={currentFunction?.path} />
</div>
</Container>
</>
);
}
FunctionDetailsPage.getLayout = function getLayout(page: ReactElement) {
return <ProjectLayout>{page}</ProjectLayout>;
};

View File

@@ -9,8 +9,8 @@ import {
useUpdateAppMutation,
} from '@/generated/graphql';
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
import CheckIcon from '@/ui/v2/icons/CheckIcon';
import Input from '@/ui/v2/Input';
import CheckIcon from '@/ui/v2/icons/CheckIcon';
import { discordAnnounce } from '@/utils/discordAnnounce';
import { slugifyString } from '@/utils/helpers';
import { updateOwnCache } from '@/utils/updateOwnCache';
@@ -53,6 +53,7 @@ export default function SettingsGeneralPage() {
const [deleteApplication] = useDeleteApplicationMutation({
variables: { appId: currentApplication?.id },
});
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
const router = useRouter();
const form = useForm<ProjectNameValidationSchema>({
@@ -69,6 +70,14 @@ export default function SettingsGeneralPage() {
const { register, formState } = form;
const handleProjectNameChange = async (data: ProjectNameValidationSchema) => {
// In this bit of code we spread the props of the current path (e.g. /workspace/...) and add one key-value pair: `updating: true`.
// We want to indicate that the currently we're in the process of running a mutation state that will affect the routing behaviour of the website
// i.e. redirecting to 404 if there's no workspace/project with that slug.
await router.replace({
pathname: router.pathname,
query: { ...router.query, updating: true },
});
const newProjectSlug = slugifyString(data.name);
if (newProjectSlug.length < 1 || newProjectSlug.length > 32) {
@@ -100,8 +109,13 @@ export default function SettingsGeneralPage() {
toastStyleProps,
);
try {
await client.refetchQueries({ include: ['getOneUser'] });
await client.refetchQueries({
include: ['getOneUser'],
});
form.reset(undefined, { keepValues: true, keepDirty: false });
await router.push(
`/${currentWorkspace.slug}/${newProjectSlug}/settings/general`,
);
} catch (error) {
await discordAnnounce(
error.message || 'Error while trying to update application cache',

View File

@@ -17215,6 +17215,27 @@ export type GetDeploymentsQueryVariables = Exact<{
export type GetDeploymentsQuery = { __typename?: 'query_root', deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null }> };
export type ScheduledOrPendingDeploymentsSubSubscriptionVariables = Exact<{
appId: Scalars['uuid'];
}>;
export type ScheduledOrPendingDeploymentsSubSubscription = { __typename?: 'subscription_root', deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null }> };
export type LatestLiveDeploymentSubSubscriptionVariables = Exact<{
appId: Scalars['uuid'];
}>;
export type LatestLiveDeploymentSubSubscription = { __typename?: 'subscription_root', deployments: Array<{ __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null }> };
export type InsertDeploymentMutationVariables = Exact<{
object: Deployments_Insert_Input;
}>;
export type InsertDeploymentMutation = { __typename?: 'mutation_root', insertDeployment?: { __typename?: 'deployments', id: any, commitSHA: string, deploymentStartedAt?: any | null, deploymentEndedAt?: any | null, deploymentStatus?: string | null, commitUserName?: string | null, commitUserAvatarUrl?: string | null, commitMessage?: string | null } | null };
export type GetDeploymentsSubSubscriptionVariables = Exact<{
id: Scalars['uuid'];
limit: Scalars['Int'];
@@ -19147,6 +19168,106 @@ export type GetDeploymentsQueryResult = Apollo.QueryResult<GetDeploymentsQuery,
export function refetchGetDeploymentsQuery(variables: GetDeploymentsQueryVariables) {
return { query: GetDeploymentsDocument, variables: variables }
}
export const ScheduledOrPendingDeploymentsSubDocument = gql`
subscription ScheduledOrPendingDeploymentsSub($appId: uuid!) {
deployments(
where: {deploymentStatus: {_in: ["PENDING", "SCHEDULED"]}, appId: {_eq: $appId}}
) {
...DeploymentRow
}
}
${DeploymentRowFragmentDoc}`;
/**
* __useScheduledOrPendingDeploymentsSubSubscription__
*
* To run a query within a React component, call `useScheduledOrPendingDeploymentsSubSubscription` and pass it any options that fit your needs.
* When your component renders, `useScheduledOrPendingDeploymentsSubSubscription` 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 subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useScheduledOrPendingDeploymentsSubSubscription({
* variables: {
* appId: // value for 'appId'
* },
* });
*/
export function useScheduledOrPendingDeploymentsSubSubscription(baseOptions: Apollo.SubscriptionHookOptions<ScheduledOrPendingDeploymentsSubSubscription, ScheduledOrPendingDeploymentsSubSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<ScheduledOrPendingDeploymentsSubSubscription, ScheduledOrPendingDeploymentsSubSubscriptionVariables>(ScheduledOrPendingDeploymentsSubDocument, options);
}
export type ScheduledOrPendingDeploymentsSubSubscriptionHookResult = ReturnType<typeof useScheduledOrPendingDeploymentsSubSubscription>;
export type ScheduledOrPendingDeploymentsSubSubscriptionResult = Apollo.SubscriptionResult<ScheduledOrPendingDeploymentsSubSubscription>;
export const LatestLiveDeploymentSubDocument = gql`
subscription LatestLiveDeploymentSub($appId: uuid!) {
deployments(
where: {deploymentStatus: {_eq: "DEPLOYED"}, appId: {_eq: $appId}}
order_by: {deploymentEndedAt: desc}
limit: 1
offset: 0
) {
...DeploymentRow
}
}
${DeploymentRowFragmentDoc}`;
/**
* __useLatestLiveDeploymentSubSubscription__
*
* To run a query within a React component, call `useLatestLiveDeploymentSubSubscription` and pass it any options that fit your needs.
* When your component renders, `useLatestLiveDeploymentSubSubscription` 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 subscription, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useLatestLiveDeploymentSubSubscription({
* variables: {
* appId: // value for 'appId'
* },
* });
*/
export function useLatestLiveDeploymentSubSubscription(baseOptions: Apollo.SubscriptionHookOptions<LatestLiveDeploymentSubSubscription, LatestLiveDeploymentSubSubscriptionVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useSubscription<LatestLiveDeploymentSubSubscription, LatestLiveDeploymentSubSubscriptionVariables>(LatestLiveDeploymentSubDocument, options);
}
export type LatestLiveDeploymentSubSubscriptionHookResult = ReturnType<typeof useLatestLiveDeploymentSubSubscription>;
export type LatestLiveDeploymentSubSubscriptionResult = Apollo.SubscriptionResult<LatestLiveDeploymentSubSubscription>;
export const InsertDeploymentDocument = gql`
mutation InsertDeployment($object: deployments_insert_input!) {
insertDeployment(object: $object) {
...DeploymentRow
}
}
${DeploymentRowFragmentDoc}`;
export type InsertDeploymentMutationFn = Apollo.MutationFunction<InsertDeploymentMutation, InsertDeploymentMutationVariables>;
/**
* __useInsertDeploymentMutation__
*
* To run a mutation, you first call `useInsertDeploymentMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useInsertDeploymentMutation` 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 [insertDeploymentMutation, { data, loading, error }] = useInsertDeploymentMutation({
* variables: {
* object: // value for 'object'
* },
* });
*/
export function useInsertDeploymentMutation(baseOptions?: Apollo.MutationHookOptions<InsertDeploymentMutation, InsertDeploymentMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<InsertDeploymentMutation, InsertDeploymentMutationVariables>(InsertDeploymentDocument, options);
}
export type InsertDeploymentMutationHookResult = ReturnType<typeof useInsertDeploymentMutation>;
export type InsertDeploymentMutationResult = Apollo.MutationResult<InsertDeploymentMutation>;
export type InsertDeploymentMutationOptions = Apollo.BaseMutationOptions<InsertDeploymentMutation, InsertDeploymentMutationVariables>;
export const GetDeploymentsSubDocument = gql`
subscription getDeploymentsSub($id: uuid!, $limit: Int!, $offset: Int!) {
deployments(

View File

@@ -1,10 +1,5 @@
import type {
FinalFunction,
Func,
} from '@/components/applications/functions/normalizeFunctionMetadata';
import features from '@/data/features.json';
import { ApplicationStatus } from '@/types/application';
import type { NextRouter } from 'next/router';
import slugify from 'slugify';
import { LOCAL_BACKEND_URL } from './env';
import type { DeploymentRowFragment } from './__generated__/graphql';
@@ -89,26 +84,6 @@ export function emptyWorkspace() {
};
}
export function yieldFunction(
functionsToSearch: FinalFunction[],
router: NextRouter,
): Func {
let functionToReturn: Func = null;
functionsToSearch.forEach((currentFolder) => {
currentFolder.funcs.forEach((currentFunction) => {
if (
!functionToReturn &&
currentFunction.functionName === router.query.functionId
) {
functionToReturn = currentFunction;
}
});
});
return functionToReturn;
}
/**
* Converts the state number of the application to its string equivalent.
* @param appStatus The current state of the application.

View File

@@ -13,6 +13,9 @@ module.exports = {
...defaultTheme.screens,
},
extend: {
animation: {
'spin-reverse': 'spin 1.5s linear infinite reverse',
},
colors: {
primary: '#0052cd',
'primary-light': '#ebf3ff',

View File

@@ -1,5 +1,17 @@
# @nhost/docs
## 0.0.11
### Patch Changes
- e6dad4d6: Added remote schemas
## 0.0.10
### Patch Changes
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
## 0.0.9
### Patch Changes

View File

@@ -0,0 +1,4 @@
{
"label": "Remote Schemas",
"position": 11
}

View File

@@ -0,0 +1,252 @@
---
title: Stripe GraphQL API
sidebar_label: Stripe
sidebar_position: 2
image: /img/og/graphql.png
---
import Tabs from '@theme/Tabs'
import TabItem from '@theme/TabItem'
This package creates a Stripe GraphQL API, allowing for interaction with data at Stripe.
Here's an example of how to use the Stripe GraphQL API to get a list of invoices for a specific Stripe customer:
<Tabs >
<TabItem value="request" label="Request" default>
```graphql
query {
stripe {
customer(id: "cus_xxx") {
id
name
invoices {
data {
id
created
paid
hostedInvoiceUrl
}
}
}
}
}
```
</TabItem>
<TabItem value="response" label="Response">
```json
{
"data": {
"stripe": {
"customer": {
"id": "cus_xxx",
"name": "joe@example.com",
"invoices": {
"data": [
{
"id": "in_1MUmwnCCF9wuB4xxxxxxxx",
"created": 1674806769,
"paid": true,
"hostedInvoiceUrl": "https://invoice.stripe.com/i/acct_xxxxxxx/test_YWNjdF8xS25xV1lDQ0Y5d3VCNGZYLF9ORkhWxxxxxxxxxxxx?s=ap"
}
]
}
}
}
}
}
```
</TabItem>
</Tabs>
It's recommended to add the Stripe GraphQL API as a [Remote Schema in Hasura](https://hasura.io/docs/latest/remote-schemas/index/) and connect data from your database with data in Stripe. By doing so, it's possible to request data from your database and Stripe in a single GraphQL query.
Here's an example of how to use the Stripe GraphQL API to get a list of invoices for a specific Stripe customer. Note that the user data is fetched from your database and the Stripe customer data is fetched from Stripe:
```graphql
query {
users {
# User in your database
id
displayName
userData {
stripeCustomerId # Customer's Stripe Customer Id
stripeCustomer {
# Data from Stripe
id
name
paymentMethods {
id
card {
brand
last4
}
}
}
}
}
}
```
## Get Started
Install the package:
<Tabs groupId="package-manager">
<TabItem value="npm" label="npm" default>
```bash
npm install @nhost/stripe-graphql-js
```
</TabItem>
<TabItem value="yarn" label="Yarn">
```bash
yarn install @nhost/stripe-graphql-js
```
</TabItem>
<TabItem value="pnpm" label="pnpm">
```bash
pnpm add @nhost/stripe-graphql-js
```
</TabItem>
</Tabs>
## Serverless Function
Create a new [Serverless Function](/serverless-functions): `functions/graphql/stripe.ts`:
```ts
import { createStripeGraphQLServer } from '@nhost/stripe-graphql-js'
const server = createStripeGraphQLServer()
export default server
```
> You can run the Stripe GraphQL API in any Node.js environment because it's built using [GraphQL Yoga](https://github.com/dotansimha/graphql-yoga).
## Stripe Secret Key
Add `STRIPE_SECRET_KEY` as an environment variable.
If you're using Nhost, add `STRIPE_SECRET_KEY` to `.env.development` like this:
```
STRIPE_SECRET_KEY=sk_test_***
```
And add the production key (`sk_live_***`) to [environment variables](/platform/environment-variables) in the Nhost dashboard.
Learn more about [Stripe API keys](https://stripe.com/docs/keys#obtain-api-keys).
## Start Nhost
```
nhost up
```
Learn more about the [Nhost CLI](/cli).
## Test
Test the Stripe GraphQL API in the browser:
[http://localhost:1337/v1/functions/graphql/stripe](http://localhost:1337/v1/functions/graphql/stripe)
## Remote Schema
Add the Stripe GraphQL API as a Remote Schema in Hasura.
**URL**
```
{{NHOST_FUNCTIONS_URL}}/graphql/stripe
```
**Headers**
```
x-nhost-webhook-secret: NHOST_WEBHOOK_SECRET (From env var)
```
> The `NHOST_WEBHOOK_SECRET` is used to verify that the request is coming from Nhost. The environment variable is a [system environment variable](/platform/environment-variables#system-environment-variables) and is always available.
![Hasura Remote Schema](/img/graphql/remote-schemas/stripe/remote-schema.png)
## Permissions
Here's a minimal example without any custom permissions. Only requests using the `x-hasura-admin-secret` header will work:
```js
const server = createStripeGraphQLServer()
```
For more granular permissions, you can pass an `isAllowed` function to the `createStripeGraphQLServer`. The `isAllowed` function takes a `stripeCustomerId` and [`context`](#context) as parameters and runs every time the GraphQL server makes a request to Stripe to get or modify data for a specific Stripe customer.
Here is an example of an `isAllowed` function:
```ts
import { createStripeGraphQLServer } from '@nhost/stripe-graphql-js'
const isAllowed = (stripeCustomerId: string, context: Context) => {
const { isAdmin, userClaims } = context
// allow all requests if they have a valid `x-hasura-admin-secret`
if (isAdmin) {
return true
}
// get user id
const userId = userClaims['x-hasura-user-id']
// check if the user is signed in
if (!userId) {
return false
}
// get more user information from the database
const { user } = await gqlSDK.getUser({
id: userId
})
if (!user) {
return false
}
// check if the user is part of a workspace with the `stripeCustomerId`
return user.workspaceMembers.some((workspaceMember) => {
return workspaceMember.workspace.stripeCustomerId === stripeCustomerId
})
}
const server = createStripeGraphQLServer({ isAllowed })
export default server
```
### Context
The `context` object contains:
- `userClaims` - verified JWT claims from the user's access token.
- `isAdmin` - `true` if the request was made using a valid `x-hasura-admin-secret` header.
- `request` - [Fetch API Request object](https://developer.mozilla.org/en-US/docs/Web/API/Request) that represents the incoming HTTP request in platform-independent way. It can be useful for accessing headers to authenticate a user
- `query` - the DocumentNode that was parsed from the GraphQL query string
- `operationName` - the operation name selected from the incoming query
- `variables` - the variables that were defined in the query
- `extensions` - the extensions that were received from the client
Read more about the [default context from GraphQL Yoga](https://www.the-guild.dev/graphql/yoga-server/docs/features/context#default-context).
## Source Code
The source code is available on [GitHub](https://github.com/nhost/nhost/tree/main/integrations/stripe-graphql-js).

View File

@@ -103,7 +103,7 @@ You can install the Nhost Next.js SDK with:
<TabItem value="npm" label="npm" default>
```bash
npm install@nhost/nextjs graphql
npm install @nhost/nextjs graphql
```
</TabItem>

View File

@@ -11,9 +11,9 @@ With Nhost, you can deploy Serverless Functions to execute custom code. Each Ser
Serverless functions can be used to handle [event triggers](/database/event-triggers), form submissions, integrations (e.g. Stripe, Slack, etc), and more.
## Creating a Serverless Function
## Create a Serverless Function
Every `.js` (JavaScript) and `.ts` (TypeScript) file in the `functions/` folder of your Nhost project is its own Serverless Function.
Every `.ts` (TypeScript) and `.js` (JavaScript) file in the `functions/` folder of your Nhost project is its own Serverless Function.
<Tabs groupId="language">
<TabItem value="ts" label="TypeScript" default>

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/docs",
"version": "0.0.9",
"version": "0.0.11",
"private": true,
"scripts": {
"docusaurus": "docusaurus",

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

View File

@@ -1,5 +1,14 @@
# @nhost-examples/codegen-react-apollo
## 0.1.5
### Patch Changes
- 200e9f77: chore(deps): update dependency @types/react-dom to v18.0.10
- Updated dependencies [200e9f77]
- @nhost/react@1.13.2
- @nhost/react-apollo@4.13.2
## 0.1.4
### Patch Changes

View File

@@ -1,11 +1,12 @@
# GraphQL Code Generator Example with React and Apollo Client
This is an example repo for how to use GraphQL Code Generator together with:
Todo app to show how to use:
- [TypeScript](https://www.typescriptlang.org/)
- [Nhost](https://nhost.io/)
- [React](https://reactjs.org/)
- [TypeScript](https://www.typescriptlang.org/)
- [GraphQL Code Generator](https://the-guild.dev/graphql/codegen)
- [Apollo Client](https://www.apollographql.com/docs/react/)
- [Nhost](http://nhost.io/)
This repo is a reference repo for the blog post: [How to use GraphQL Code Generator with React and Apollo](https://nhost.io/blog/how-to-use-graphql-code-generator-with-react-and-apollo).
@@ -13,42 +14,42 @@ This repo is a reference repo for the blog post: [How to use GraphQL Code Genera
1. Clone the repository
```
```sh
git clone https://github.com/nhost/nhost
cd nhost
```
2. Install and build dependencies
```
```sh
pnpm install
pnpm build
pnpm run build
```
3. Go to the Codegen React Apollo example folder
3. Go to the example folder
```
cd examples/codegen-react-apollo
```sh
cd examples/codegen-react-urql
```
4. Terminal 1: Start Nhost
> Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli).
```sh
nhost up
nhost up -d
```
5. Terminal 2: Run GraphQL Codegen
5. Terminal 2: Start the React application
```sh
pnpm run dev
```
## GraphQL Code Generators
To re-run the GraphQL Code Generators, run the following:
```
pnpm codegen -w
```
> `-w` runs [codegen in watch mode](https://www.the-guild.dev/graphql/codegen/docs/getting-started/development-workflow#watch-mode).
6. Terminal 3: Start the React application
```sh
pnpm dev
```

View File

@@ -2,13 +2,3 @@ schema:
- http://localhost:1337/v1/graphql:
headers:
x-hasura-admin-secret: nhost-admin-secret
documents:
- 'src/**/*.graphql'
generates:
src/utils/__generated__/graphql.ts:
plugins:
- 'typescript'
- 'typescript-operations'
- 'typescript-react-apollo'
config:
withRefetchFn: true

View File

@@ -9,6 +9,6 @@
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,14 +1,25 @@
metadata_directory: metadata
services:
hasura:
image: hasura/graphql-engine:v2.15.2
environment:
hasura_graphql_enable_remote_schema_permissions: false
image: hasura/graphql-engine:v2.15.2
minio:
environment:
minio_root_password: minioaccesskey123123
minio_root_user: minioaccesskey123123
postgres:
environment:
postgres_password: postgres
postgres_user: postgres
auth:
image: nhost/hasura-auth:0.16.2
image: nhost/hasura-auth:0.16.1
storage:
image: nhost/hasura-storage:0.3.0
auth:
webauthn:
enabled: true
rp_name: URQL
access_control:
email:
allowed_email_domains: ''
@@ -18,13 +29,13 @@ auth:
url:
allowed_redirect_urls: ''
anonymous_users_enabled: false
client_url: http://localhost:3000
client_url: http://localhost:5173
disable_new_users: false
email:
enabled: false
passwordless:
enabled: false
signin_email_verified_required: true
enabled: true
signin_email_verified_required: false
template_fetch_url: ''
gravatar:
default: ''
@@ -117,11 +128,7 @@ auth:
secure: false
sender: hasura-auth@example.com
user: user
token:
access:
expires_in: 900
refresh:
expires_in: 43200
access_token_expires_in: 315
user:
allowed_roles: user,me
default_allowed_roles: user,me

View File

@@ -9,6 +9,6 @@
connection_lifetime: 600
idle_timeout: 180
max_connections: 50
retries: 1
retries: 20
use_prepared_statements: true
tables: "!include default/tables/tables.yaml"

View File

@@ -2,8 +2,14 @@ table:
name: provider_requests
schema: auth
configuration:
column_config: {}
custom_column_names: {}
column_config:
id:
custom_name: id
options:
custom_name: options
custom_column_names:
id: id
options: options
custom_name: authProviderRequests
custom_root_fields:
delete: deleteAuthProviderRequests

View File

@@ -2,8 +2,11 @@ table:
name: providers
schema: auth
configuration:
column_config: {}
custom_column_names: {}
column_config:
id:
custom_name: id
custom_column_names:
id: id
custom_name: authProviders
custom_root_fields:
delete: deleteAuthProviders

View File

@@ -2,8 +2,11 @@ table:
name: roles
schema: auth
configuration:
column_config: {}
custom_column_names: {}
column_config:
role:
custom_name: role
custom_column_names:
role: role
custom_name: authRoles
custom_root_fields:
delete: deleteAuthRoles

View File

@@ -1,30 +0,0 @@
table:
name: user_authenticators
schema: auth
configuration:
column_config:
credential_id:
custom_name: credentialId
credential_public_key:
custom_name: credentialPublicKey
user_id:
custom_name: userId
custom_column_names:
credential_id: credentialId
credential_public_key: credentialPublicKey
user_id: userId
custom_name: authUserAuthenticators
custom_root_fields:
delete: deleteAuthUserAuthenticators
delete_by_pk: deleteAuthUserAuthenticator
insert: insertAuthUserAuthenticators
insert_one: insertAuthUserAuthenticator
select: authUserAuthenticators
select_aggregate: authUserAuthenticatorsAggregate
select_by_pk: authUserAuthenticator
update: updateAuthUserAuthenticators
update_by_pk: updateAuthUserAuthenticator
object_relationships:
- name: user
using:
foreign_key_constraint_on: user_id

View File

@@ -7,6 +7,8 @@ configuration:
custom_name: accessToken
created_at:
custom_name: createdAt
id:
custom_name: id
provider_id:
custom_name: providerId
provider_user_id:
@@ -20,6 +22,7 @@ configuration:
custom_column_names:
access_token: accessToken
created_at: createdAt
id: id
provider_id: providerId
provider_user_id: providerUserId
refresh_token: refreshToken

View File

@@ -5,10 +5,16 @@ configuration:
column_config:
created_at:
custom_name: createdAt
id:
custom_name: id
role:
custom_name: role
user_id:
custom_name: userId
custom_column_names:
created_at: createdAt
id: id
role: role
user_id: userId
custom_name: authUserRoles
custom_root_fields:

View File

@@ -11,14 +11,22 @@ configuration:
custom_name: createdAt
default_role:
custom_name: defaultRole
disabled:
custom_name: disabled
display_name:
custom_name: displayName
email:
custom_name: email
email_verified:
custom_name: emailVerified
id:
custom_name: id
is_anonymous:
custom_name: isAnonymous
last_seen:
custom_name: lastSeen
locale:
custom_name: locale
new_email:
custom_name: newEmail
otp_hash:
@@ -33,6 +41,8 @@ configuration:
custom_name: phoneNumber
phone_number_verified:
custom_name: phoneNumberVerified
ticket:
custom_name: ticket
ticket_expires_at:
custom_name: ticketExpiresAt
totp_secret:
@@ -46,10 +56,14 @@ configuration:
avatar_url: avatarUrl
created_at: createdAt
default_role: defaultRole
disabled: disabled
display_name: displayName
email: email
email_verified: emailVerified
id: id
is_anonymous: isAnonymous
last_seen: lastSeen
locale: locale
new_email: newEmail
otp_hash: otpHash
otp_hash_expires_at: otpHashExpiresAt
@@ -57,6 +71,7 @@ configuration:
password_hash: passwordHash
phone_number: phoneNumber
phone_number_verified: phoneNumberVerified
ticket: ticket
ticket_expires_at: ticketExpiresAt
totp_secret: totpSecret
updated_at: updatedAt
@@ -77,13 +92,6 @@ object_relationships:
using:
foreign_key_constraint_on: default_role
array_relationships:
- name: authenticators
using:
foreign_key_constraint_on:
column: user_id
table:
name: user_authenticators
schema: auth
- name: refreshTokens
using:
foreign_key_constraint_on:
@@ -98,6 +106,13 @@ array_relationships:
table:
name: user_roles
schema: auth
- name: securityKeys
using:
foreign_key_constraint_on:
column: user_id
table:
name: user_security_keys
schema: auth
- name: userProviders
using:
foreign_key_constraint_on:
@@ -106,12 +121,17 @@ array_relationships:
name: user_providers
schema: auth
select_permissions:
- role: public
permission:
columns:
- display_name
- id
filter: {}
limit: 0
- role: user
permission:
columns:
- avatar_url
- display_name
- id
filter:
id:
_eq: X-Hasura-User-Id
filter: {}
limit: 0

View File

@@ -1,17 +0,0 @@
table:
name: customers
schema: public
insert_permissions:
- role: public
permission:
check: {}
columns:
- name
select_permissions:
- role: public
permission:
columns:
- id
- name
- created_at
filter: {}

View File

@@ -1,80 +0,0 @@
table:
name: doc_links
schema: public
configuration:
column_config:
created_at:
custom_name: createdAt
doc_id:
custom_name: docId
download_allowed:
custom_name: downloadAllowed
is_active:
custom_name: isActive
require_email_to_view:
custom_name: requireEmailToView
updated_at:
custom_name: updatedAt
custom_column_names:
created_at: createdAt
doc_id: docId
download_allowed: downloadAllowed
is_active: isActive
require_email_to_view: requireEmailToView
updated_at: updatedAt
custom_root_fields:
insert: insertDocLinks
insert_one: insertDocLink
select: docLinks
select_by_pk: docLink
object_relationships:
- name: doc
using:
foreign_key_constraint_on: doc_id
array_relationships:
- name: docVisits
using:
foreign_key_constraint_on:
column: doc_link_id
table:
name: doc_visits
schema: public
insert_permissions:
- permission:
check:
doc:
user_id:
_eq: X-Hasura-User-Id
columns:
- doc_id
- download_allowed
- is_active
- passcode
- require_email_to_view
role: user
select_permissions:
- permission:
columns:
- download_allowed
- id
- is_active
- passcode
- require_email_to_view
filter: {}
limit: 0
role: public
- permission:
columns:
- download_allowed
- is_active
- require_email_to_view
- passcode
- created_at
- updated_at
- doc_id
- id
filter:
doc:
user_id:
_eq: X-Hasura-User-Id
role: user

View File

@@ -1,35 +0,0 @@
table:
name: doc_visits
schema: public
configuration:
column_config:
created_at:
custom_name: createdAt
doc_link_id:
custom_name: docLinkId
updated_at:
custom_name: updatedAt
custom_column_names:
created_at: createdAt
doc_link_id: docLinkId
updated_at: updatedAt
custom_root_fields: {}
object_relationships:
- name: docLink
using:
foreign_key_constraint_on: doc_link_id
select_permissions:
- permission:
allow_aggregations: true
columns:
- email
- created_at
- updated_at
- doc_link_id
- id
filter:
docLink:
doc:
user_id:
_eq: X-Hasura-User-Id
role: user

View File

@@ -1,72 +0,0 @@
table:
name: docs
schema: public
configuration:
column_config:
created_at:
custom_name: createdAt
file_id:
custom_name: fileId
updated_at:
custom_name: updatedAt
user_id:
custom_name: userId
custom_column_names:
created_at: createdAt
file_id: fileId
updated_at: updatedAt
user_id: userId
custom_root_fields:
delete: deleteDocs
delete_by_pk: DeleteDoc
insert: insertDocs
insert_one: insertDoc
select: docs
select_aggregate: docsAggregate
select_by_pk: doc
update: updateDocs
update_by_pk: updateDoc
object_relationships:
- name: file
using:
foreign_key_constraint_on: file_id
- name: user
using:
foreign_key_constraint_on: user_id
array_relationships:
- name: docLinks
using:
foreign_key_constraint_on:
column: doc_id
table:
name: doc_links
schema: public
insert_permissions:
- permission:
check: {}
columns:
- file_id
- name
set:
user_id: x-hasura-user-id
role: user
select_permissions:
- permission:
columns:
- file_id
- id
filter: {}
limit: 0
role: public
- permission:
columns:
- name
- created_at
- updated_at
- file_id
- id
- user_id
filter:
user_id:
_eq: X-Hasura-User-Id
role: user

View File

@@ -9,6 +9,8 @@ configuration:
custom_name: createdAt
download_expiration:
custom_name: downloadExpiration
id:
custom_name: id
max_upload_file_size:
custom_name: maxUploadFileSize
min_upload_file_size:
@@ -21,6 +23,7 @@ configuration:
cache_control: cacheControl
created_at: createdAt
download_expiration: downloadExpiration
id: id
max_upload_file_size: maxUploadFileSize
min_upload_file_size: minUploadFileSize
presigned_urls_enabled: presignedUrlsEnabled

View File

@@ -9,10 +9,14 @@ configuration:
custom_name: createdAt
etag:
custom_name: etag
id:
custom_name: id
is_uploaded:
custom_name: isUploaded
mime_type:
custom_name: mimeType
name:
custom_name: name
size:
custom_name: size
updated_at:
@@ -23,8 +27,10 @@ configuration:
bucket_id: bucketId
created_at: createdAt
etag: etag
id: id
is_uploaded: isUploaded
mime_type: mimeType
name: name
size: size
updated_at: updatedAt
uploaded_by_user_id: uploadedByUserId

View File

@@ -2,10 +2,10 @@
- "!include auth_providers.yaml"
- "!include auth_refresh_tokens.yaml"
- "!include auth_roles.yaml"
- "!include auth_user_authenticators.yaml"
- "!include auth_user_providers.yaml"
- "!include auth_user_roles.yaml"
- "!include auth_user_security_keys.yaml"
- "!include auth_users.yaml"
- "!include public_customers.yaml"
- "!include public_tasks.yaml"
- "!include storage_buckets.yaml"
- "!include storage_files.yaml"

View File

@@ -1 +0,0 @@
CREATE TABLE "public"."customers" ("id" serial NOT NULL, "created_at" timestamptz NOT NULL DEFAULT now(), "name" text NOT NULL, PRIMARY KEY ("id") );

View File

@@ -1,17 +1,9 @@
{
"name": "@nhost-examples/codegen-react-apollo",
"version": "0.1.4",
"version": "0.1.5",
"private": true,
"dependencies": {
"@apollo/client": "^3.6.9",
"@nhost/react": "*",
"@nhost/react-apollo": "*",
"graphql": "15.7.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"scripts": {
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
"codegen": "graphql-codegen",
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host localhost --port 3000"
@@ -22,28 +14,28 @@
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
"dependencies": {
"@apollo/client": "^3.7.3",
"@nhost/react": "*",
"@nhost/react-apollo": "*",
"clsx": "^1.2.1",
"graphql": "15.7.2",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@graphql-codegen/cli": "^2.12.0",
"@graphql-codegen/typescript-operations": "^2.5.3",
"@graphql-codegen/typescript-react-apollo": "^3.3.3",
"@types/node": "^16.11.7",
"@graphql-codegen/cli": "^2.14.1",
"@graphql-codegen/client-preset": "^1.1.5",
"@graphql-typed-document-node/core": "^3.1.1",
"@tailwindcss/forms": "^0.5.3",
"@types/node": "^18.11.9",
"@types/react": "^18.0.25",
"@types/react-dom": "^18.0.9",
"eslint": "^8.23.0",
"eslint-config-react-app": "^7.0.1",
"typescript": "^4.8.2",
"@vitejs/plugin-react": "^3.0.0",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.19",
"tailwindcss": "^3.2.1",
"typescript": "^4.6.4",
"vite": "^4.0.2"
}
}

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