Compare commits
231 Commits
@nhost/nho
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c1996b13b | ||
|
|
e3d90fd5d2 | ||
|
|
af016e1caa | ||
|
|
ed4c780115 | ||
|
|
941f0f5755 | ||
|
|
75344b2bc0 | ||
|
|
65da426e8b | ||
|
|
f34702f3c5 | ||
|
|
6cb70eee01 | ||
|
|
9395c9687f | ||
|
|
eb1eb934a4 | ||
|
|
c62fed2c9a | ||
|
|
16fe1a47da | ||
|
|
0f04e8b8b8 | ||
|
|
e6dad4d696 | ||
|
|
bcb3b79add | ||
|
|
fe658231b4 | ||
|
|
a1188b7d98 | ||
|
|
cd4bdc581d | ||
|
|
4e2f8ccd52 | ||
|
|
8a6d8c7534 | ||
|
|
fa75409f09 | ||
|
|
74662052ae | ||
|
|
37ab5fe878 | ||
|
|
be9af96fa7 | ||
|
|
31abbe5f30 | ||
|
|
268b461d5b | ||
|
|
58af592cfa | ||
|
|
0e9d623c69 | ||
|
|
412a290646 | ||
|
|
123add38a4 | ||
|
|
5bdd31ad36 | ||
|
|
5121851c8b | ||
|
|
8ca1f92491 | ||
|
|
5535b9085b | ||
|
|
bc51122b25 | ||
|
|
b060e5e550 | ||
|
|
6a906b22e2 | ||
|
|
860c9d1be4 | ||
|
|
9eec3e58f5 | ||
|
|
4e01a43e94 | ||
|
|
c126b20dcf | ||
|
|
b727a24a5f | ||
|
|
ecadd7e1b9 | ||
|
|
2d661174a8 | ||
|
|
fcb3e5192f | ||
|
|
66fdc63f38 | ||
|
|
fa37cb6171 | ||
|
|
c1bea1294d | ||
|
|
8af2f6e9dd | ||
|
|
e3d0b96917 | ||
|
|
43705b992d | ||
|
|
2e999e8715 | ||
|
|
0370696d5c | ||
|
|
f62131d55a | ||
|
|
36c3519cf8 | ||
|
|
86d077ac00 | ||
|
|
200e9f774c | ||
|
|
9b52e9bf13 | ||
|
|
bc1235de3b | ||
|
|
fce58ebaea | ||
|
|
452e281120 | ||
|
|
9a338e54c9 | ||
|
|
baeebf980d | ||
|
|
ac92c6ee61 | ||
|
|
1ddaf680c0 | ||
|
|
c6e6194d8e | ||
|
|
83deea8b45 | ||
|
|
07c8d90053 | ||
|
|
acbaabcf85 | ||
|
|
a2621e40a4 | ||
|
|
3534501f37 | ||
|
|
27bc23cbbc | ||
|
|
61120a137a | ||
|
|
faea8feb2e | ||
|
|
6450223558 | ||
|
|
a62a85a777 | ||
|
|
ae24f83953 | ||
|
|
fc60d7a782 | ||
|
|
6be8a998df | ||
|
|
ea091f6251 | ||
|
|
8175c052f7 | ||
|
|
e6605a6ed0 | ||
|
|
1cba0e6492 | ||
|
|
179c90fcdb | ||
|
|
552e31a4f0 | ||
|
|
85f0f943a1 | ||
|
|
c4c23fde31 | ||
|
|
e0b94c3e90 | ||
|
|
113d638532 | ||
|
|
d87448916f | ||
|
|
af4292658c | ||
|
|
f735bcd2ea | ||
|
|
66fb74af86 | ||
|
|
791eac30bb | ||
|
|
da4ad889d7 | ||
|
|
9ef111760c | ||
|
|
c2706c7d97 | ||
|
|
683b8768c4 | ||
|
|
6d9df237a8 | ||
|
|
220ae37aa7 | ||
|
|
d0d94d9239 | ||
|
|
aed3d1f147 | ||
|
|
d07bf08e45 | ||
|
|
f2183250d2 | ||
|
|
d2bb5ecfae | ||
|
|
02d0db0cf0 | ||
|
|
441005d5c3 | ||
|
|
eea8708549 | ||
|
|
5f3f9390aa | ||
|
|
1c5b0560ed | ||
|
|
1bfdf21b99 | ||
|
|
c4561cae38 | ||
|
|
efd522a38a | ||
|
|
55c35fa9c5 | ||
|
|
d42c27ae99 | ||
|
|
927be4a2c9 | ||
|
|
e44352abbd | ||
|
|
f9289f3c32 | ||
|
|
8ff06e5637 | ||
|
|
49e4633bca | ||
|
|
7ae7a7206c | ||
|
|
43d7e7babf | ||
|
|
463a51ce7c | ||
|
|
86e9d9d47f | ||
|
|
f99b72cd7c | ||
|
|
0dc2f3ff29 | ||
|
|
d0f8081101 | ||
|
|
84ebfb79d0 | ||
|
|
3c78d0ef46 | ||
|
|
e9a26fc995 | ||
|
|
b0794507f5 | ||
|
|
824e222e9d | ||
|
|
16a99d7d0f | ||
|
|
cda5c3d274 | ||
|
|
3d3791286d | ||
|
|
ad28bf2166 | ||
|
|
17bfa83204 | ||
|
|
6cd64e76ff | ||
|
|
a4bf50cf23 | ||
|
|
113baafd84 | ||
|
|
87c2b31821 | ||
|
|
8a6bc3625c | ||
|
|
bdfda8aced | ||
|
|
ca090436af | ||
|
|
55f85a04ea | ||
|
|
73f95cfa3b | ||
|
|
dbd3ded515 | ||
|
|
5399fac211 | ||
|
|
52e3127a34 | ||
|
|
3fb12c189b | ||
|
|
c4d5366b22 | ||
|
|
bd68e916cf | ||
|
|
7cadd9447b | ||
|
|
7432c6477c | ||
|
|
c3aa6126fe | ||
|
|
0f3cf887c1 | ||
|
|
5cd311b69a | ||
|
|
057fda178f | ||
|
|
241b14a004 | ||
|
|
10b56089fa | ||
|
|
973df1ed5a | ||
|
|
8f681b83e8 | ||
|
|
21501624e6 | ||
|
|
464530dacb | ||
|
|
0f2fc3dfec | ||
|
|
5cb71f1dc8 | ||
|
|
83e0a4d33e | ||
|
|
e9ef254c6d | ||
|
|
09af118452 | ||
|
|
20d0c3d09b | ||
|
|
d92891b223 | ||
|
|
aef86dc822 | ||
|
|
a3499c4628 | ||
|
|
3cac6f69bd | ||
|
|
71ff71ccd2 | ||
|
|
da575ca262 | ||
|
|
5020566725 | ||
|
|
eb5915aa03 | ||
|
|
458ee7fe6c | ||
|
|
ea7eb18f36 | ||
|
|
18f5414411 | ||
|
|
a7ce6d85f4 | ||
|
|
2f348c660a | ||
|
|
599387934c | ||
|
|
04cea41111 | ||
|
|
dc3723306d | ||
|
|
d7fa572ab6 | ||
|
|
7c07d09ea4 | ||
|
|
13876ed523 | ||
|
|
a529b654bc | ||
|
|
c21118257f | ||
|
|
4712b7ff68 | ||
|
|
4f305a8985 | ||
|
|
cd7d133ba3 | ||
|
|
2927a9ac31 | ||
|
|
695eaa77ca | ||
|
|
a29d21e194 | ||
|
|
cd20bd4ef2 | ||
|
|
b112ba0af4 | ||
|
|
e6d990faa7 | ||
|
|
b45da7e360 | ||
|
|
ff186a8d09 | ||
|
|
3061771908 | ||
|
|
c681cc9bef | ||
|
|
3a80504427 | ||
|
|
e025c5857f | ||
|
|
c3c95053dc | ||
|
|
b27e94c712 | ||
|
|
279cf78aa5 | ||
|
|
8817adddf6 | ||
|
|
1388f11508 | ||
|
|
c2bfed6e1f | ||
|
|
97dc261fcd | ||
|
|
4ca93c2773 | ||
|
|
f8b32584b4 | ||
|
|
8de1be4910 | ||
|
|
d0f9ffba73 | ||
|
|
fcb4d167e7 | ||
|
|
c5137c6c45 | ||
|
|
297c2a965d | ||
|
|
9194be4816 | ||
|
|
199fd0d491 | ||
|
|
cd4b58674a | ||
|
|
4f20d8640d | ||
|
|
5e8ae336a2 | ||
|
|
1ff73f4f00 | ||
|
|
7ce44ae1b1 | ||
|
|
1b847617b6 | ||
|
|
df53ec2954 | ||
|
|
7b4c32816e |
@@ -2,20 +2,7 @@
|
|||||||
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
|
"$schema": "https://unpkg.com/@changesets/config@1.6.0/schema.json",
|
||||||
"changelog": "@changesets/cli/changelog",
|
"changelog": "@changesets/cli/changelog",
|
||||||
"commit": false,
|
"commit": false,
|
||||||
"linked": [
|
"linked": [],
|
||||||
[
|
|
||||||
"@nhost/nextjs",
|
|
||||||
"@nhost/react",
|
|
||||||
"@nhost/vue",
|
|
||||||
"@nhost/nhost-js",
|
|
||||||
"@nhost/hasura-auth-js",
|
|
||||||
"@nhost/hasura-storage-js"
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"@nhost/react-apollo",
|
|
||||||
"@nhost/apollo"
|
|
||||||
]
|
|
||||||
],
|
|
||||||
"access": "restricted",
|
"access": "restricted",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
|
|||||||
2
.github/actions/nhost-cli/action.yaml
vendored
2
.github/actions/nhost-cli/action.yaml
vendored
@@ -38,7 +38,7 @@ runs:
|
|||||||
uses: nick-fields/retry@v2
|
uses: nick-fields/retry@v2
|
||||||
with:
|
with:
|
||||||
timeout_minutes: 3
|
timeout_minutes: 3
|
||||||
max_attempts: 3
|
max_attempts: 10
|
||||||
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
|
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
|
||||||
- name: Set custom configuration
|
- name: Set custom configuration
|
||||||
if: ${{ inputs.config }}
|
if: ${{ inputs.config }}
|
||||||
|
|||||||
@@ -1,5 +1,96 @@
|
|||||||
# @nhost/dashboard
|
# @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
|
||||||
|
|
||||||
|
- Updated dependencies [d42c27ae]
|
||||||
|
- Updated dependencies [927be4a2]
|
||||||
|
- @nhost/nextjs@1.13.1
|
||||||
|
- @nhost/react-apollo@4.13.1
|
||||||
|
|
||||||
|
## 0.9.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- d0f80811: fix(dashboard): don't show error when signing out the user
|
||||||
|
|
||||||
|
## 0.9.0
|
||||||
|
|
||||||
|
### Minor Changes
|
||||||
|
|
||||||
|
- d92891b2: feat(dashboard): add Permission Editor to the Database section
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 3d379128: fix(dashboard): create new user
|
||||||
|
- @nhost/react-apollo@4.13.0
|
||||||
|
- @nhost/nextjs@1.13.0
|
||||||
|
|
||||||
|
## 0.8.1
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- 7cadd944: fix(dashboard): display Twitter provider settings
|
||||||
|
|
||||||
## 0.8.0
|
## 0.8.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ RUN apk add --no-cache libc6-compat
|
|||||||
RUN apk update
|
RUN apk update
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN yarn global add turbo
|
RUN yarn global add turbo@1
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN turbo prune --scope="@nhost/dashboard" --docker
|
RUN turbo prune --scope="@nhost/dashboard" --docker
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Nhost Dashboard
|
# 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).
|
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
|
```bash
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "0.8.0",
|
"version": "0.10.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "next build --no-lint",
|
"build": "next build --no-lint",
|
||||||
"analyze": "ANALYZE=true pnpm build --no-lint",
|
"analyze": "ANALYZE=true pnpm build --no-lint",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint --max-warnings 3",
|
"lint": "next lint --max-warnings 2",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
"codegen": "graphql-codegen --config graphql.config.yaml --errors-only",
|
||||||
"nhost:dev": "nhost dev -d",
|
"nhost:dev": "nhost dev -d",
|
||||||
@@ -105,14 +105,14 @@
|
|||||||
"@types/node": "^16.11.7",
|
"@types/node": "^16.11.7",
|
||||||
"@types/pluralize": "^0.0.29",
|
"@types/pluralize": "^0.0.29",
|
||||||
"@types/react": "18.0.25",
|
"@types/react": "18.0.25",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "18.0.10",
|
||||||
"@types/react-table": "^7.7.12",
|
"@types/react-table": "^7.7.12",
|
||||||
"@types/testing-library__jest-dom": "^5.14.5",
|
"@types/testing-library__jest-dom": "^5.14.5",
|
||||||
"@types/validator": "^13.7.10",
|
"@types/validator": "^13.7.10",
|
||||||
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
"@typescript-eslint/eslint-plugin": "^5.43.0",
|
||||||
"@typescript-eslint/parser": "^5.43.0",
|
"@typescript-eslint/parser": "^5.43.0",
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
"@vitejs/plugin-react": "^3.0.0",
|
||||||
"@vitest/coverage-c8": "^0.26.0",
|
"@vitest/coverage-c8": "^0.27.0",
|
||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"babel-loader": "^8.3.0",
|
"babel-loader": "^8.3.0",
|
||||||
"babel-plugin-transform-remove-console": "^6.9.4",
|
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
"typescript": "^4.8.4",
|
"typescript": "^4.8.4",
|
||||||
"vite": "^4.0.2",
|
"vite": "^4.0.2",
|
||||||
"vite-tsconfig-paths": "^4.0.3",
|
"vite-tsconfig-paths": "^4.0.3",
|
||||||
"vitest": "^0.26.2",
|
"vitest": "^0.27.0",
|
||||||
"webpack": "^5.75.0"
|
"webpack": "^5.75.0"
|
||||||
},
|
},
|
||||||
"browserslist": {
|
"browserslist": {
|
||||||
|
|||||||
@@ -1,21 +1,14 @@
|
|||||||
import type { DeploymentRowFragment } from '@/generated/graphql';
|
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
|
||||||
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 {
|
import {
|
||||||
differenceInSeconds,
|
useGetDeploymentsSubSubscription,
|
||||||
formatDistanceToNowStrict,
|
useLatestLiveDeploymentSubSubscription,
|
||||||
parseISO,
|
useScheduledOrPendingDeploymentsSubSubscription,
|
||||||
} from 'date-fns';
|
} 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 Link from 'next/link';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
type AppDeploymentsProps = {
|
type AppDeploymentsProps = {
|
||||||
appId: string;
|
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) {
|
export default function AppDeployments(props: AppDeploymentsProps) {
|
||||||
const { appId } = props;
|
const { appId } = props;
|
||||||
const [idOfLiveDeployment, setIdOfLiveDeployment] = useState('');
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -216,36 +71,59 @@ export default function AppDeployments(props: AppDeploymentsProps) {
|
|||||||
const limit = 10;
|
const limit = 10;
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// @TODO: Should query for all deployments, then subscribe to new ones.
|
const {
|
||||||
|
data: deploymentPageData,
|
||||||
const { data, loading, error } = useGetDeploymentsSubSubscription({
|
loading: deploymentPageLoading,
|
||||||
variables: {
|
error,
|
||||||
id: appId,
|
} = useGetDeploymentsSubSubscription({
|
||||||
limit,
|
variables: { id: appId, limit, offset },
|
||||||
offset,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: latestDeploymentData, loading: latestDeploymentLoading } =
|
||||||
if (!data) {
|
useGetDeploymentsSubSubscription({
|
||||||
return;
|
variables: { id: appId, limit: 1, offset: 0 },
|
||||||
}
|
});
|
||||||
|
|
||||||
if (page === 1) {
|
const {
|
||||||
setIdOfLiveDeployment(getLastLiveDeployment(data.deployments));
|
data: latestLiveDeploymentData,
|
||||||
}
|
loading: latestLiveDeploymentLoading,
|
||||||
}, [data, idOfLiveDeployment, loading, page]);
|
} = useLatestLiveDeploymentSubSubscription({ variables: { appId } });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: scheduledOrPendingDeploymentsData,
|
||||||
|
loading: scheduledOrPendingDeploymentsLoading,
|
||||||
|
} = useScheduledOrPendingDeploymentsSubSubscription({ variables: { appId } });
|
||||||
|
|
||||||
|
const loading =
|
||||||
|
deploymentPageLoading ||
|
||||||
|
scheduledOrPendingDeploymentsLoading ||
|
||||||
|
latestDeploymentLoading ||
|
||||||
|
latestLiveDeploymentLoading;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <DelayedLoading delay={500} className="mt-12" />;
|
return (
|
||||||
|
<ActivityIndicator
|
||||||
|
delay={500}
|
||||||
|
className="mt-12"
|
||||||
|
label="Loading deployments..."
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
throw 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 nextAllowed = !(nrOfDeployments < limit);
|
||||||
|
const liveDeploymentId = latestLiveDeployment?.id || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
@@ -253,15 +131,17 @@ export default function AppDeployments(props: AppDeploymentsProps) {
|
|||||||
<p className="text-sm text-greyscaleGrey">No deployments yet.</p>
|
<p className="text-sm text-greyscaleGrey">No deployments yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<div className="mt-3 divide-y-1 border-t border-b">
|
<List className="mt-3 divide-y-1 border-t border-b">
|
||||||
{data.deployments.map((deployment) => (
|
{deployments.map((deployment) => (
|
||||||
<AppDeploymentRow
|
<DeploymentListItem
|
||||||
deployment={deployment}
|
|
||||||
key={deployment.id}
|
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="mt-8 flex w-full justify-center">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<NextPrevPageLink
|
<NextPrevPageLink
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type FunctionLog = {
|
|
||||||
name: string;
|
|
||||||
language: string;
|
|
||||||
logs: { date: string; message: string; createdAt: string }[];
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export type FunctionResponseLog = {
|
|
||||||
functionPath: string;
|
|
||||||
createdAt: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
@@ -34,13 +34,13 @@ export function Repo({ repo, setSelectedRepoId }: RepoProps) {
|
|||||||
const [updateApp, { loading, error }] = useUpdateAppMutation({
|
const [updateApp, { loading, error }] = useUpdateAppMutation({
|
||||||
refetchQueries: [
|
refetchQueries: [
|
||||||
refetchGetAppByWorkspaceAndNameQuery({
|
refetchGetAppByWorkspaceAndNameQuery({
|
||||||
workspace: currentWorkspace.slug,
|
workspace: currentWorkspace?.slug,
|
||||||
slug: currentApplication.slug,
|
slug: currentApplication?.slug,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const { githubRepository } = currentApplication;
|
const { githubRepository } = currentApplication || {};
|
||||||
|
|
||||||
const isThisRepositoryAlreadyConnected =
|
const isThisRepositoryAlreadyConnected =
|
||||||
githubRepository?.fullName && githubRepository.fullName === repo.fullName;
|
githubRepository?.fullName && githubRepository.fullName === repo.fullName;
|
||||||
|
|||||||
@@ -52,12 +52,14 @@ function ControlledAutocomplete(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
|
inputValue={typeof field.value === 'string' ? field.value : undefined}
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
inputValue={typeof field.value === 'string' ? field.value : undefined}
|
|
||||||
ref={mergeRefs([field.ref, ref])}
|
ref={mergeRefs([field.ref, ref])}
|
||||||
onChange={(event, options, reason, details) => {
|
onChange={(event, options, reason, details) => {
|
||||||
setValue?.(controllerProps?.name || name, options);
|
setValue?.(controllerProps?.name || name, options, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (props.onChange) {
|
if (props.onChange) {
|
||||||
props.onChange(event, options, reason, details);
|
props.onChange(event, options, reason, details);
|
||||||
|
|||||||
@@ -42,13 +42,16 @@ function ControlledSwitch(
|
|||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
ref={mergeRefs([field.ref, ref])}
|
ref={mergeRefs([field.ref, ref])}
|
||||||
onChange={(e) => {
|
onChange={(event) => {
|
||||||
setValue(controllerProps?.name || name, e.target.checked, {
|
setValue(controllerProps?.name || name, event.target.checked, {
|
||||||
shouldDirty: true,
|
shouldDirty: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (props.onChange) {
|
||||||
|
props.onChange(event);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
checked={field.value || false}
|
checked={field.value || false}
|
||||||
{...props}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import { createContext } from 'react';
|
|||||||
* Available dialog types.
|
* Available dialog types.
|
||||||
*/
|
*/
|
||||||
export type DialogType =
|
export type DialogType =
|
||||||
|
| 'EDIT_WORKSPACE_NAME'
|
||||||
| 'CREATE_RECORD'
|
| 'CREATE_RECORD'
|
||||||
| 'CREATE_COLUMN'
|
| 'CREATE_COLUMN'
|
||||||
| 'EDIT_COLUMN'
|
| 'EDIT_COLUMN'
|
||||||
| 'CREATE_TABLE'
|
| 'CREATE_TABLE'
|
||||||
| 'EDIT_TABLE'
|
| 'EDIT_TABLE'
|
||||||
|
| 'EDIT_PERMISSIONS'
|
||||||
| 'CREATE_FOREIGN_KEY'
|
| 'CREATE_FOREIGN_KEY'
|
||||||
| 'EDIT_FOREIGN_KEY'
|
| 'EDIT_FOREIGN_KEY'
|
||||||
| 'CREATE_ROLE'
|
| 'CREATE_ROLE'
|
||||||
@@ -66,6 +68,16 @@ export interface DialogContextProps {
|
|||||||
* Call this function to close the active drawer.
|
* Call this function to close the active drawer.
|
||||||
*/
|
*/
|
||||||
closeDrawer: VoidFunction;
|
closeDrawer: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Call this function to check if the form is dirty and close the active dialog
|
||||||
|
* if the form is pristine.
|
||||||
|
*/
|
||||||
|
closeDialogWithDirtyGuard: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Call this function to check if the form is dirty and close the active drawer
|
||||||
|
* if the form is pristine.
|
||||||
|
*/
|
||||||
|
closeDrawerWithDirtyGuard: VoidFunction;
|
||||||
/**
|
/**
|
||||||
* Call this function to close the active alert dialog.
|
* Call this function to close the active alert dialog.
|
||||||
*/
|
*/
|
||||||
@@ -77,6 +89,10 @@ export interface DialogContextProps {
|
|||||||
isDirty: boolean,
|
isDirty: boolean,
|
||||||
location?: 'drawer' | 'dialog',
|
location?: 'drawer' | 'dialog',
|
||||||
) => void;
|
) => void;
|
||||||
|
/**
|
||||||
|
* Call this function to open a dirty confirmation dialog.
|
||||||
|
*/
|
||||||
|
openDirtyConfirmation: (config?: Partial<DialogConfig<string>>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createContext<DialogContextProps>({
|
export default createContext<DialogContextProps>({
|
||||||
@@ -85,6 +101,9 @@ export default createContext<DialogContextProps>({
|
|||||||
openAlertDialog: () => {},
|
openAlertDialog: () => {},
|
||||||
closeDialog: () => {},
|
closeDialog: () => {},
|
||||||
closeDrawer: () => {},
|
closeDrawer: () => {},
|
||||||
|
closeDialogWithDirtyGuard: () => {},
|
||||||
|
closeDrawerWithDirtyGuard: () => {},
|
||||||
closeAlertDialog: () => {},
|
closeAlertDialog: () => {},
|
||||||
onDirtyStateChange: () => {},
|
onDirtyStateChange: () => {},
|
||||||
|
openDirtyConfirmation: () => {},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||||
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
|
import CreateForeignKeyForm from '@/components/dataBrowser/CreateForeignKeyForm';
|
||||||
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
import EditForeignKeyForm from '@/components/dataBrowser/EditForeignKeyForm';
|
||||||
|
import EditWorkspaceNameForm from '@/components/home/EditWorkspaceNameForm';
|
||||||
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
import CreateEnvironmentVariableForm from '@/components/settings/environmentVariables/CreateEnvironmentVariableForm';
|
||||||
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
import EditEnvironmentVariableForm from '@/components/settings/environmentVariables/EditEnvironmentVariableForm';
|
||||||
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
import EditJwtSecretForm from '@/components/settings/environmentVariables/EditJwtSecretForm';
|
||||||
@@ -85,6 +86,11 @@ const EditTableForm = dynamic(
|
|||||||
{ ssr: false, loading: () => LoadingComponent() },
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const EditPermissionsForm = dynamic(
|
||||||
|
() => import('@/components/dataBrowser/EditPermissionsForm'),
|
||||||
|
{ ssr: false, loading: () => LoadingComponent() },
|
||||||
|
);
|
||||||
|
|
||||||
function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -196,23 +202,31 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
function closeDrawerWithDirtyGuard(event?: BaseSyntheticEvent) {
|
const closeDrawerWithDirtyGuard = useCallback(
|
||||||
if (isDrawerDirty.current && event?.type !== 'submit') {
|
(event?: BaseSyntheticEvent) => {
|
||||||
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
|
if (isDrawerDirty.current && event?.type !== 'submit') {
|
||||||
return;
|
setShowDirtyConfirmation(true);
|
||||||
}
|
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
closeDrawer();
|
closeDrawer();
|
||||||
}
|
},
|
||||||
|
[closeDrawer, openDirtyConfirmation],
|
||||||
|
);
|
||||||
|
|
||||||
function closeDialogWithDirtyGuard(event?: BaseSyntheticEvent) {
|
const closeDialogWithDirtyGuard = useCallback(
|
||||||
if (isDialogDirty.current && event?.type !== 'submit') {
|
(event?: BaseSyntheticEvent) => {
|
||||||
openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } });
|
if (isDialogDirty.current && event?.type !== 'submit') {
|
||||||
return;
|
setShowDirtyConfirmation(true);
|
||||||
}
|
openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
}
|
},
|
||||||
|
[closeDialog, openDirtyConfirmation],
|
||||||
|
);
|
||||||
|
|
||||||
// We are coupling this logic with the location of the dialog content which is
|
// We are coupling this logic with the location of the dialog content which is
|
||||||
// not ideal. We shoule figure out a better logic for tracking the dirty
|
// not ideal. We shoule figure out a better logic for tracking the dirty
|
||||||
@@ -239,10 +253,22 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
openAlertDialog,
|
openAlertDialog,
|
||||||
closeDialog,
|
closeDialog,
|
||||||
closeDrawer,
|
closeDrawer,
|
||||||
|
closeDialogWithDirtyGuard,
|
||||||
|
closeDrawerWithDirtyGuard,
|
||||||
closeAlertDialog,
|
closeAlertDialog,
|
||||||
onDirtyStateChange,
|
onDirtyStateChange,
|
||||||
|
openDirtyConfirmation,
|
||||||
}),
|
}),
|
||||||
[closeDialog, closeDrawer, onDirtyStateChange, openDialog, openDrawer],
|
[
|
||||||
|
closeDialog,
|
||||||
|
closeDialogWithDirtyGuard,
|
||||||
|
closeDrawer,
|
||||||
|
closeDrawerWithDirtyGuard,
|
||||||
|
onDirtyStateChange,
|
||||||
|
openDialog,
|
||||||
|
openDirtyConfirmation,
|
||||||
|
openDrawer,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sharedDialogProps = {
|
const sharedDialogProps = {
|
||||||
@@ -341,6 +367,10 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
<RetryableErrorBoundary
|
<RetryableErrorBoundary
|
||||||
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
errorMessageProps={{ className: 'pt-0 pb-5 px-6' }}
|
||||||
>
|
>
|
||||||
|
{activeDialogType === 'EDIT_WORKSPACE_NAME' && (
|
||||||
|
<EditWorkspaceNameForm {...sharedDialogProps} />
|
||||||
|
)}
|
||||||
|
|
||||||
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
|
{activeDialogType === 'CREATE_FOREIGN_KEY' && (
|
||||||
<CreateForeignKeyForm {...sharedDialogProps} />
|
<CreateForeignKeyForm {...sharedDialogProps} />
|
||||||
)}
|
)}
|
||||||
@@ -391,13 +421,19 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
|
|
||||||
<Drawer
|
<Drawer
|
||||||
|
anchor="right"
|
||||||
{...drawerProps}
|
{...drawerProps}
|
||||||
title={drawerTitle}
|
title={drawerTitle}
|
||||||
open={drawerOpen}
|
open={drawerOpen}
|
||||||
onClose={closeDrawerWithDirtyGuard}
|
onClose={closeDrawerWithDirtyGuard}
|
||||||
SlideProps={{ onExited: clearDrawerContent, unmountOnExit: false }}
|
SlideProps={{ onExited: clearDrawerContent, unmountOnExit: false }}
|
||||||
anchor="right"
|
PaperProps={{
|
||||||
PaperProps={{ className: 'max-w-2.5xl w-full' }}
|
...drawerProps?.PaperProps,
|
||||||
|
className: twMerge(
|
||||||
|
'max-w-2.5xl w-full',
|
||||||
|
drawerProps?.PaperProps?.className,
|
||||||
|
),
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<RetryableErrorBoundary>
|
<RetryableErrorBoundary>
|
||||||
{activeDrawerType === 'CREATE_RECORD' && (
|
{activeDrawerType === 'CREATE_RECORD' && (
|
||||||
@@ -433,6 +469,15 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeDrawerType === 'EDIT_PERMISSIONS' && (
|
||||||
|
<EditPermissionsForm
|
||||||
|
{...sharedDrawerProps}
|
||||||
|
disabled={drawerPayload?.disabled}
|
||||||
|
schema={drawerPayload?.schema}
|
||||||
|
table={drawerPayload?.table}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeDrawerType === 'EDIT_USER' && (
|
{activeDrawerType === 'EDIT_USER' && (
|
||||||
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
|
<EditUserForm {...sharedDrawerProps} {...drawerPayload} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import InlineCode from '@/components/common/InlineCode';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
export default function HighlightedText({
|
||||||
|
children,
|
||||||
|
}: PropsWithChildren<unknown>) {
|
||||||
|
return (
|
||||||
|
<InlineCode className="text-greyscaleDark bg-primary-light font-display text-sm">
|
||||||
|
{children}
|
||||||
|
</InlineCode>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
dashboard/src/components/common/HighlightedText/index.ts
Normal file
1
dashboard/src/components/common/HighlightedText/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './HighlightedText';
|
||||||
@@ -8,7 +8,7 @@ function InlineCode({ className, children, ...props }: InlineCodeProps) {
|
|||||||
return (
|
return (
|
||||||
<code
|
<code
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'inline-grid h-full max-h-[18px] max-w-xs items-center truncate rounded-sm bg-gray-100 px-1 font-mono text-[11px] text-gray-600',
|
'inline-grid max-w-xs items-center truncate rounded-sm bg-gray-100 px-1 font-mono text-[11px] text-greyscaleMedium',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import { ChangePasswordModal } from '@/components/applications/ChangePasswordModal';
|
import { ChangePasswordModal } from '@/components/applications/ChangePasswordModal';
|
||||||
import { useWorkspaceContext } from '@/context/workspace-context';
|
|
||||||
import { useUserDataContext } from '@/context/workspace1-context';
|
|
||||||
import { Avatar } from '@/ui/Avatar';
|
import { Avatar } from '@/ui/Avatar';
|
||||||
import { Modal } from '@/ui/Modal';
|
import { Modal } from '@/ui/Modal';
|
||||||
import Button from '@/ui/v2/Button';
|
import Button from '@/ui/v2/Button';
|
||||||
import { Dropdown, useDropdown } from '@/ui/v2/Dropdown';
|
import { Dropdown, useDropdown } from '@/ui/v2/Dropdown';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
import { emptyWorkspace } from '@/utils/helpers';
|
|
||||||
import { nhost } from '@/utils/nhost';
|
import { nhost } from '@/utils/nhost';
|
||||||
|
import { useApolloClient } from '@apollo/client';
|
||||||
import { useUserData } from '@nhost/nextjs';
|
import { useUserData } from '@nhost/nextjs';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -22,9 +20,8 @@ function AccountMenuContent({
|
|||||||
}: AccountMenuContentProps) {
|
}: AccountMenuContentProps) {
|
||||||
const user = useUserData();
|
const user = useUserData();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const client = useApolloClient();
|
||||||
const [clicked, setClicked] = useState(false);
|
const [clicked, setClicked] = useState(false);
|
||||||
const { setWorkspaceContext } = useWorkspaceContext();
|
|
||||||
const { setUserContext } = useUserDataContext();
|
|
||||||
const { handleClose } = useDropdown();
|
const { handleClose } = useDropdown();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -34,10 +31,9 @@ function AccountMenuContent({
|
|||||||
color="secondary"
|
color="secondary"
|
||||||
className="absolute top-6 right-4 grid grid-flow-col items-center gap-1 self-start font-medium"
|
className="absolute top-6 right-4 grid grid-flow-col items-center gap-1 self-start font-medium"
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setWorkspaceContext(emptyWorkspace());
|
|
||||||
setUserContext({ workspaces: [] });
|
|
||||||
nhost.auth.signOut();
|
|
||||||
router.push('/signin');
|
router.push('/signin');
|
||||||
|
await nhost.auth.signOut();
|
||||||
|
await client.resetStore();
|
||||||
}}
|
}}
|
||||||
aria-label="Sign Out"
|
aria-label="Sign Out"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export default function BaseColumnForm({
|
|||||||
variant="inline"
|
variant="inline"
|
||||||
className="col-span-8 py-3"
|
className="col-span-8 py-3"
|
||||||
autoFocus
|
autoFocus
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ControlledAutocomplete
|
<ControlledAutocomplete
|
||||||
@@ -272,6 +273,7 @@ export default function BaseColumnForm({
|
|||||||
error={Boolean(errors.comment)}
|
error={Boolean(errors.comment)}
|
||||||
variant="inline"
|
variant="inline"
|
||||||
className="col-span-8 py-3"
|
className="col-span-8 py-3"
|
||||||
|
autoComplete="off"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ function NameInput() {
|
|||||||
error={Boolean(errors.name)}
|
error={Boolean(errors.name)}
|
||||||
variant="inline"
|
variant="inline"
|
||||||
className="col-span-8 py-3"
|
className="col-span-8 py-3"
|
||||||
|
autoComplete="off"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ function NameInput({ index }: FieldArrayInputProps) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
|
autoComplete="off"
|
||||||
aria-label="Name"
|
aria-label="Name"
|
||||||
placeholder="Enter name"
|
placeholder="Enter name"
|
||||||
hideEmptyHelperText
|
hideEmptyHelperText
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export default function ColumnEditorTable() {
|
|||||||
startIcon={<PlusIcon />}
|
startIcon={<PlusIcon />}
|
||||||
size="small"
|
size="small"
|
||||||
>
|
>
|
||||||
Add column
|
Add Column
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import type {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { forwardRef, useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useState } from 'react';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { UseAsyncInitialValueOptions } from './useAsyncInitialValue';
|
import type { UseAsyncValueOptions } from './useAsyncValue';
|
||||||
import useAsyncInitialValue from './useAsyncInitialValue';
|
import useAsyncValue from './useAsyncValue';
|
||||||
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
import type { UseColumnGroupsOptions } from './useColumnGroups';
|
||||||
import useColumnGroups from './useColumnGroups';
|
import useColumnGroups from './useColumnGroups';
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export interface ColumnAutocompleteProps
|
|||||||
/**
|
/**
|
||||||
* Function to be called when the input is asynchronously initialized.
|
* Function to be called when the input is asynchronously initialized.
|
||||||
*/
|
*/
|
||||||
onInitialized?: UseAsyncInitialValueOptions['onInitialized'];
|
onInitialized?: UseAsyncValueOptions['onInitialized'];
|
||||||
/**
|
/**
|
||||||
* Class name to be applied to the root element.
|
* Class name to be applied to the root element.
|
||||||
*/
|
*/
|
||||||
@@ -107,7 +107,11 @@ function ColumnAutocomplete(
|
|||||||
ref: ForwardedRef<HTMLInputElement>,
|
ref: ForwardedRef<HTMLInputElement>,
|
||||||
) {
|
) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [activeRelationship, setActiveRelationship] = useState<any>();
|
const [activeRelationship, setActiveRelationship] = useState<{
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
name: string;
|
||||||
|
}>();
|
||||||
const selectedSchema = activeRelationship?.schema || defaultSchema;
|
const selectedSchema = activeRelationship?.schema || defaultSchema;
|
||||||
const selectedTable = activeRelationship?.table || defaultTable;
|
const selectedTable = activeRelationship?.table || defaultTable;
|
||||||
|
|
||||||
@@ -119,6 +123,7 @@ function ColumnAutocomplete(
|
|||||||
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
} = useTableQuery([`default.${selectedSchema}.${selectedTable}`], {
|
||||||
schema: selectedSchema,
|
schema: selectedSchema,
|
||||||
table: selectedTable,
|
table: selectedTable,
|
||||||
|
preventRowFetching: true,
|
||||||
queryOptions: { refetchOnWindowFocus: false },
|
queryOptions: { refetchOnWindowFocus: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,7 +146,7 @@ function ColumnAutocomplete(
|
|||||||
setSelectedRelationships,
|
setSelectedRelationships,
|
||||||
relationshipDotNotation,
|
relationshipDotNotation,
|
||||||
activeRelationship: asyncActiveRelationship,
|
activeRelationship: asyncActiveRelationship,
|
||||||
} = useAsyncInitialValue({
|
} = useAsyncValue({
|
||||||
selectedSchema,
|
selectedSchema,
|
||||||
selectedTable,
|
selectedTable,
|
||||||
initialValue: externalValue as string,
|
initialValue: externalValue as string,
|
||||||
@@ -226,7 +231,7 @@ function ColumnAutocomplete(
|
|||||||
inputValue,
|
inputValue,
|
||||||
options,
|
options,
|
||||||
id: props?.name,
|
id: props?.name,
|
||||||
openOnFocus: true,
|
openOnFocus: !props.disabled,
|
||||||
disableCloseOnSelect: true,
|
disableCloseOnSelect: true,
|
||||||
value: selectedColumn,
|
value: selectedColumn,
|
||||||
onClose: () => setOpen(false),
|
onClose: () => setOpen(false),
|
||||||
@@ -256,24 +261,43 @@ function ColumnAutocomplete(
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
onClick={() => setOpen(true)}
|
if (props.disabled) {
|
||||||
error={Boolean(tableError || metadataError)}
|
return;
|
||||||
helperText={String(tableError || metadataError || '')}
|
}
|
||||||
|
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (props.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
error={Boolean(tableError || metadataError) || props.error}
|
||||||
|
helperText={
|
||||||
|
String(tableError || metadataError || '') || props.helperText
|
||||||
|
}
|
||||||
onChange={(event) => setInputValue(event.target.value)}
|
onChange={(event) => setInputValue(event.target.value)}
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
startAdornment={
|
startAdornment={
|
||||||
selectedColumn || relationshipDotNotation ? (
|
selectedColumn || relationshipDotNotation ? (
|
||||||
<Text className="!ml-2 lg:max-w-[200px] flex-shrink-0 truncate">
|
<Text
|
||||||
|
className={twMerge(
|
||||||
|
'!ml-2 lg:max-w-[200px] flex-shrink-0 truncate',
|
||||||
|
props.disabled && 'text-greyscaleGrey',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<span className="text-greyscaleGrey">{defaultTable}</span>.
|
<span className="text-greyscaleGrey">{defaultTable}</span>.
|
||||||
{relationshipDotNotation && (
|
{relationshipDotNotation && (
|
||||||
<>
|
<>
|
||||||
<span className="hidden lg:inline">
|
<span className="hidden lg:inline">
|
||||||
{getTruncatedText(relationshipDotNotation, 15, 'start')}.
|
{getTruncatedText(relationshipDotNotation, 15, 'end')}.
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span className="inline lg:hidden">
|
<span className="inline lg:hidden">
|
||||||
{relationshipDotNotation}.
|
{getTruncatedText(relationshipDotNotation, 35, 'end')}.
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { HasuraMetadataTable } from '@/types/dataBrowser';
|
|||||||
import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
|
import type { AutocompleteOption } from '@/ui/v2/Autocomplete';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export interface UseAsyncInitialValueOptions {
|
export interface UseAsyncValueOptions {
|
||||||
/**
|
/**
|
||||||
* Selected schema to be used to determine the initial value.
|
* Selected schema to be used to determine the initial value.
|
||||||
*/
|
*/
|
||||||
@@ -42,7 +42,7 @@ export interface UseAsyncInitialValueOptions {
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function useAsyncInitialValue({
|
export default function useAsyncValue({
|
||||||
selectedSchema,
|
selectedSchema,
|
||||||
selectedTable,
|
selectedTable,
|
||||||
initialValue,
|
initialValue,
|
||||||
@@ -51,7 +51,7 @@ export default function useAsyncInitialValue({
|
|||||||
tableData,
|
tableData,
|
||||||
metadata,
|
metadata,
|
||||||
onInitialized,
|
onInitialized,
|
||||||
}: UseAsyncInitialValueOptions) {
|
}: UseAsyncValueOptions) {
|
||||||
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
const currentTablePath = `${selectedSchema}.${selectedTable}`;
|
||||||
const [inputValue, setInputValue] = useState('');
|
const [inputValue, setInputValue] = useState('');
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
@@ -65,10 +65,9 @@ export default function useAsyncInitialValue({
|
|||||||
const [selectedRelationships, setSelectedRelationships] = useState<
|
const [selectedRelationships, setSelectedRelationships] = useState<
|
||||||
{ schema: string; table: string; name: string }[]
|
{ schema: string; table: string; name: string }[]
|
||||||
>([]);
|
>([]);
|
||||||
const relationshipDotNotation =
|
const relationshipDotNotation = selectedRelationships
|
||||||
initialized && selectedRelationships?.length > 0
|
.map((relationship) => relationship.name)
|
||||||
? selectedRelationships.map((relationship) => relationship.name).join('.')
|
.join('.');
|
||||||
: '';
|
|
||||||
const [selectedColumn, setSelectedColumn] =
|
const [selectedColumn, setSelectedColumn] =
|
||||||
useState<AutocompleteOption>(null);
|
useState<AutocompleteOption>(null);
|
||||||
const activeRelationship =
|
const activeRelationship =
|
||||||
@@ -146,7 +145,8 @@ export default function useAsyncInitialValue({
|
|||||||
remainingColumnPath.length < 2 ||
|
remainingColumnPath.length < 2 ||
|
||||||
isTableLoading ||
|
isTableLoading ||
|
||||||
isMetadataLoading ||
|
isMetadataLoading ||
|
||||||
!tableData?.columns
|
!tableData?.columns ||
|
||||||
|
asyncTablePath !== currentTablePath
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -167,8 +167,8 @@ export default function useAsyncInitialValue({
|
|||||||
|
|
||||||
const tableMetadata = metadataMap.get(`${selectedSchema}.${selectedTable}`);
|
const tableMetadata = metadataMap.get(`${selectedSchema}.${selectedTable}`);
|
||||||
const currentRelationship = [
|
const currentRelationship = [
|
||||||
...(tableMetadata.object_relationships || []),
|
...(tableMetadata?.object_relationships || []),
|
||||||
...(tableMetadata.array_relationships || []),
|
...(tableMetadata?.array_relationships || []),
|
||||||
].find(({ name }) => name === nextPath);
|
].find(({ name }) => name === nextPath);
|
||||||
|
|
||||||
if (!currentRelationship) {
|
if (!currentRelationship) {
|
||||||
@@ -176,11 +176,32 @@ export default function useAsyncInitialValue({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { foreign_key_constraint_on: metadataConstraint } =
|
const {
|
||||||
currentRelationship.using || {};
|
foreign_key_constraint_on: metadataConstraint,
|
||||||
|
manual_configuration: metadataManualConfiguration,
|
||||||
|
} = currentRelationship.using || {};
|
||||||
|
|
||||||
|
if (metadataManualConfiguration) {
|
||||||
|
setAsyncTablePath(
|
||||||
|
`${metadataManualConfiguration.remote_table.schema}.${metadataManualConfiguration.remote_table.name}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
setSelectedRelationships((currentRelationships) => [
|
||||||
|
...currentRelationships,
|
||||||
|
{
|
||||||
|
schema: metadataManualConfiguration.remote_table.schema || 'public',
|
||||||
|
table: metadataManualConfiguration.remote_table.name,
|
||||||
|
name: nextPath,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// In some cases the metadata already contains the schema and table name
|
// In some cases the metadata already contains the schema and table name
|
||||||
if (typeof metadataConstraint !== 'string') {
|
if (metadataConstraint && typeof metadataConstraint !== 'string') {
|
||||||
setAsyncTablePath(
|
setAsyncTablePath(
|
||||||
`${metadataConstraint.table.schema || 'public'}.${
|
`${metadataConstraint.table.schema || 'public'}.${
|
||||||
metadataConstraint.table.name
|
metadataConstraint.table.name
|
||||||
@@ -203,17 +224,25 @@ export default function useAsyncInitialValue({
|
|||||||
|
|
||||||
const foreignKeyRelation = tableData?.foreignKeyRelations?.find(
|
const foreignKeyRelation = tableData?.foreignKeyRelations?.find(
|
||||||
({ columnName }) => {
|
({ columnName }) => {
|
||||||
const { foreign_key_constraint_on } = currentRelationship.using || {};
|
const normalizedColumnName = columnName.replace(/"/g, '');
|
||||||
|
const { foreign_key_constraint_on, manual_configuration } =
|
||||||
|
currentRelationship.using || {};
|
||||||
|
|
||||||
if (!foreign_key_constraint_on) {
|
if (!foreign_key_constraint_on && !manual_configuration) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof foreign_key_constraint_on === 'string') {
|
if (manual_configuration) {
|
||||||
return foreign_key_constraint_on === columnName;
|
return Object.keys(manual_configuration.column_mapping).includes(
|
||||||
|
normalizedColumnName,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return foreign_key_constraint_on.column === columnName;
|
if (typeof foreign_key_constraint_on === 'string') {
|
||||||
|
return foreign_key_constraint_on === normalizedColumnName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return foreign_key_constraint_on.column === normalizedColumnName;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -222,23 +251,30 @@ export default function useAsyncInitialValue({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setAsyncTablePath(
|
const normalizedSchema = foreignKeyRelation.referencedSchema?.replace(
|
||||||
`${foreignKeyRelation.referencedSchema || 'public'}.${
|
/(\\"|")/g,
|
||||||
foreignKeyRelation.referencedTable
|
'',
|
||||||
}`,
|
|
||||||
);
|
);
|
||||||
|
const normalizedTable = foreignKeyRelation.referencedTable?.replace(
|
||||||
|
/(\\"|")/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
setAsyncTablePath(`${normalizedSchema || 'public'}.${normalizedTable}`);
|
||||||
|
|
||||||
setSelectedRelationships((currentRelationships) => [
|
setSelectedRelationships((currentRelationships) => [
|
||||||
...currentRelationships,
|
...currentRelationships,
|
||||||
{
|
{
|
||||||
schema: foreignKeyRelation.referencedSchema || 'public',
|
schema: normalizedSchema || 'public',
|
||||||
table: foreignKeyRelation.referencedTable,
|
table: normalizedTable,
|
||||||
name: nextPath,
|
name: nextPath,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
setRemainingColumnPath((columnPath) => columnPath.slice(1));
|
||||||
}, [
|
}, [
|
||||||
|
currentTablePath,
|
||||||
|
asyncTablePath,
|
||||||
selectedSchema,
|
selectedSchema,
|
||||||
selectedTable,
|
selectedTable,
|
||||||
metadata?.tables,
|
metadata?.tables,
|
||||||
@@ -258,6 +294,9 @@ export default function useAsyncInitialValue({
|
|||||||
selectedColumn: initialized ? selectedColumn : null,
|
selectedColumn: initialized ? selectedColumn : null,
|
||||||
setSelectedRelationships,
|
setSelectedRelationships,
|
||||||
setSelectedColumn,
|
setSelectedColumn,
|
||||||
relationshipDotNotation,
|
relationshipDotNotation:
|
||||||
|
initialized && selectedRelationships?.length > 0
|
||||||
|
? relationshipDotNotation
|
||||||
|
: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -65,25 +65,44 @@ export default function useColumnGroups({
|
|||||||
const objectAndArrayRelationships = [
|
const objectAndArrayRelationships = [
|
||||||
...(object_relationships || []),
|
...(object_relationships || []),
|
||||||
...(array_relationships || []),
|
...(array_relationships || []),
|
||||||
].map((relationship) => {
|
].reduce((relationships, currentRelationship) => {
|
||||||
const { foreign_key_constraint_on } = relationship?.using || {};
|
const { foreign_key_constraint_on, manual_configuration } =
|
||||||
|
currentRelationship?.using || {};
|
||||||
|
|
||||||
if (typeof foreign_key_constraint_on === 'string') {
|
if (manual_configuration) {
|
||||||
return {
|
return [
|
||||||
schema: selectedSchema,
|
...relationships,
|
||||||
table: selectedTable,
|
...Object.keys(manual_configuration.column_mapping).map((column) => ({
|
||||||
column: foreign_key_constraint_on,
|
schema: manual_configuration.remote_table?.schema || 'public',
|
||||||
name: relationship.name,
|
table: manual_configuration.remote_table?.name,
|
||||||
};
|
column,
|
||||||
|
name: currentRelationship.name,
|
||||||
|
})),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
if (typeof foreign_key_constraint_on === 'string') {
|
||||||
schema: foreign_key_constraint_on.table.schema,
|
return [
|
||||||
table: foreign_key_constraint_on.table.name,
|
...relationships,
|
||||||
column: foreign_key_constraint_on.column,
|
{
|
||||||
name: relationship.name,
|
schema: selectedSchema,
|
||||||
};
|
table: selectedTable,
|
||||||
});
|
column: foreign_key_constraint_on,
|
||||||
|
name: currentRelationship.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
...relationships,
|
||||||
|
{
|
||||||
|
schema: foreign_key_constraint_on.table.schema,
|
||||||
|
table: foreign_key_constraint_on.table.name,
|
||||||
|
column: foreign_key_constraint_on.column,
|
||||||
|
name: currentRelationship.name,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [] as { schema: string; table: string; column: string; name: string }[]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...columnOptions,
|
...columnOptions,
|
||||||
@@ -93,6 +112,9 @@ export default function useColumnGroups({
|
|||||||
group: 'relationships',
|
group: 'relationships',
|
||||||
metadata: {
|
metadata: {
|
||||||
target: {
|
target: {
|
||||||
|
schema: relationship.schema,
|
||||||
|
table: relationship.table,
|
||||||
|
column: relationship.column,
|
||||||
...(columnTargetMap?.get(relationship.column) || {}),
|
...(columnTargetMap?.get(relationship.column) || {}),
|
||||||
name: relationship.name,
|
name: relationship.name,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -348,7 +348,7 @@ export default function DataBrowserGrid({
|
|||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Schema{' '}
|
Schema{' '}
|
||||||
<InlineCode className="max-h-[32px] bg-gray-200 bg-opacity-80 px-1.5 text-sm">
|
<InlineCode className="bg-gray-200 bg-opacity-80 px-1.5 text-sm">
|
||||||
{metadata.schema || schemaSlug}
|
{metadata.schema || schemaSlug}
|
||||||
</InlineCode>{' '}
|
</InlineCode>{' '}
|
||||||
does not exist.
|
does not exist.
|
||||||
@@ -365,7 +365,7 @@ export default function DataBrowserGrid({
|
|||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
Table{' '}
|
Table{' '}
|
||||||
<InlineCode className="max-h-[32px] bg-gray-200 bg-opacity-80 px-1.5 text-sm">
|
<InlineCode className="bg-gray-200 bg-opacity-80 px-1.5 text-sm">
|
||||||
{metadata.schema || schemaSlug}.{metadata.table || tableSlug}
|
{metadata.schema || schemaSlug}.{metadata.table || tableSlug}
|
||||||
</InlineCode>{' '}
|
</InlineCode>{' '}
|
||||||
does not exist.
|
does not exist.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import InlineCode from '@/components/common/InlineCode';
|
||||||
import NavLink from '@/components/common/NavLink';
|
import NavLink from '@/components/common/NavLink';
|
||||||
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
import RetryableErrorBoundary from '@/components/common/RetryableErrorBoundary';
|
||||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||||
@@ -8,6 +9,7 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
|
|||||||
import FloatingActionButton from '@/ui/FloatingActionButton';
|
import FloatingActionButton from '@/ui/FloatingActionButton';
|
||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import Button from '@/ui/v2/Button';
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Chip from '@/ui/v2/Chip';
|
||||||
import Divider from '@/ui/v2/Divider';
|
import Divider from '@/ui/v2/Divider';
|
||||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
import IconButton from '@/ui/v2/IconButton';
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
@@ -17,6 +19,7 @@ import LockIcon from '@/ui/v2/icons/LockIcon';
|
|||||||
import PencilIcon from '@/ui/v2/icons/PencilIcon';
|
import PencilIcon from '@/ui/v2/icons/PencilIcon';
|
||||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||||
import TrashIcon from '@/ui/v2/icons/TrashIcon';
|
import TrashIcon from '@/ui/v2/icons/TrashIcon';
|
||||||
|
import UsersIcon from '@/ui/v2/icons/UsersIcon';
|
||||||
import Link from '@/ui/v2/Link';
|
import Link from '@/ui/v2/Link';
|
||||||
import List from '@/ui/v2/List';
|
import List from '@/ui/v2/List';
|
||||||
import { ListItem } from '@/ui/v2/ListItem';
|
import { ListItem } from '@/ui/v2/ListItem';
|
||||||
@@ -194,6 +197,40 @@ function DataBrowserSidebarContent({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEditPermissionClick(
|
||||||
|
schema: string,
|
||||||
|
table: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
) {
|
||||||
|
openDrawer('EDIT_PERMISSIONS', {
|
||||||
|
title: (
|
||||||
|
<span className="inline-grid grid-flow-col gap-2 items-center">
|
||||||
|
Permissions
|
||||||
|
<InlineCode className="!text-sm+ font-normal text-greyscaleMedium">
|
||||||
|
{table}
|
||||||
|
</InlineCode>
|
||||||
|
<Chip label="Preview" size="small" color="info" component="span" />
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
PaperProps: {
|
||||||
|
className: 'lg:w-[65%] lg:max-w-7xl',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
onSubmit: async () => {
|
||||||
|
await queryClient.refetchQueries([
|
||||||
|
`${dataSourceSlug}.${schema}.${table}`,
|
||||||
|
]);
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
disabled,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-1">
|
<div className="grid gap-1">
|
||||||
{schemas && schemas.length > 0 && (
|
{schemas && schemas.length > 0 && (
|
||||||
@@ -318,9 +355,7 @@ function DataBrowserSidebarContent({
|
|||||||
<Dropdown.Trigger
|
<Dropdown.Trigger
|
||||||
asChild
|
asChild
|
||||||
hideChevron
|
hideChevron
|
||||||
disabled={
|
disabled={tablePath === removableTable}
|
||||||
tablePath === removableTable || isGitHubConnected
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="borderless"
|
variant="borderless"
|
||||||
@@ -329,7 +364,6 @@ function DataBrowserSidebarContent({
|
|||||||
!isSelected &&
|
!isSelected &&
|
||||||
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
'opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 group-active:opacity-100',
|
||||||
)}
|
)}
|
||||||
disabled={isGitHubConnected}
|
|
||||||
>
|
>
|
||||||
<DotsHorizontalIcon />
|
<DotsHorizontalIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
@@ -339,44 +373,84 @@ function DataBrowserSidebarContent({
|
|||||||
menu
|
menu
|
||||||
PaperProps={{ className: 'w-52' }}
|
PaperProps={{ className: 'w-52' }}
|
||||||
>
|
>
|
||||||
<Dropdown.Item
|
{isGitHubConnected ? (
|
||||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
<Dropdown.Item
|
||||||
onClick={() =>
|
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||||
openDrawer('EDIT_TABLE', {
|
onClick={() =>
|
||||||
title: 'Edit Table',
|
handleEditPermissionClick(
|
||||||
payload: {
|
table.table_schema,
|
||||||
onSubmit: async () => {
|
table.table_name,
|
||||||
await queryClient.refetchQueries([
|
true,
|
||||||
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
)
|
||||||
]);
|
}
|
||||||
await refetch();
|
>
|
||||||
},
|
<UsersIcon className="h-4 w-4 text-gray-700" />
|
||||||
schema: table.table_schema,
|
|
||||||
table,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<PencilIcon className="h-4 w-4 text-gray-700" />
|
|
||||||
|
|
||||||
<span>Edit Table</span>
|
<span>View Permissions</span>
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
|
) : (
|
||||||
|
[
|
||||||
|
<Dropdown.Item
|
||||||
|
key="edit-table"
|
||||||
|
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||||
|
onClick={() =>
|
||||||
|
openDrawer('EDIT_TABLE', {
|
||||||
|
title: 'Edit Table',
|
||||||
|
payload: {
|
||||||
|
onSubmit: async () => {
|
||||||
|
await queryClient.refetchQueries([
|
||||||
|
`${dataSourceSlug}.${table.table_schema}.${table.table_name}`,
|
||||||
|
]);
|
||||||
|
await refetch();
|
||||||
|
},
|
||||||
|
schema: table.table_schema,
|
||||||
|
table,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4 text-gray-700" />
|
||||||
|
|
||||||
<Divider component="li" />
|
<span>Edit Table</span>
|
||||||
|
</Dropdown.Item>,
|
||||||
|
<Divider
|
||||||
|
key="edit-table-separator"
|
||||||
|
component="li"
|
||||||
|
/>,
|
||||||
|
<Dropdown.Item
|
||||||
|
key="edit-permissions"
|
||||||
|
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||||
|
onClick={() =>
|
||||||
|
handleEditPermissionClick(
|
||||||
|
table.table_schema,
|
||||||
|
table.table_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UsersIcon className="h-4 w-4 text-gray-700" />
|
||||||
|
|
||||||
<Dropdown.Item
|
<span>Edit Permissions</span>
|
||||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red"
|
</Dropdown.Item>,
|
||||||
onClick={() =>
|
<Divider
|
||||||
handleDeleteTableClick(
|
key="edit-permissions-separator"
|
||||||
table.table_schema,
|
component="li"
|
||||||
table.table_name,
|
/>,
|
||||||
)
|
<Dropdown.Item
|
||||||
}
|
key="delete-table"
|
||||||
>
|
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium text-red"
|
||||||
<TrashIcon className="h-4 w-4 text-red" />
|
onClick={() =>
|
||||||
|
handleDeleteTableClick(
|
||||||
|
table.table_schema,
|
||||||
|
table.table_name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4 text-red" />
|
||||||
|
|
||||||
<span>Delete Table</span>
|
<span>Delete Table</span>
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>,
|
||||||
|
]
|
||||||
|
)}
|
||||||
</Dropdown.Content>
|
</Dropdown.Content>
|
||||||
</Dropdown.Root>
|
</Dropdown.Root>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function DatabaseRecordInputGroup({
|
|||||||
<span>{columnId}</span>
|
<span>{columnId}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<InlineCode>
|
<InlineCode className="h-[18px]">
|
||||||
{specificType}
|
{specificType}
|
||||||
{maxLength ? `(${maxLength})` : null}
|
{maxLength ? `(${maxLength})` : null}
|
||||||
</InlineCode>
|
</InlineCode>
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import useMetadataQuery from '@/hooks/dataBrowser/useMetadataQuery';
|
||||||
|
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||||
|
import type {
|
||||||
|
DatabaseAccessLevel,
|
||||||
|
DatabaseAction,
|
||||||
|
HasuraMetadataPermission,
|
||||||
|
} from '@/types/dataBrowser';
|
||||||
|
import { Alert } from '@/ui/Alert';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import FullPermissionIcon from '@/ui/v2/icons/FullPermissionIcon';
|
||||||
|
import NoPermissionIcon from '@/ui/v2/icons/NoPermissionIcon';
|
||||||
|
import PartialPermissionIcon from '@/ui/v2/icons/PartialPermissionIcon';
|
||||||
|
import Link from '@/ui/v2/Link';
|
||||||
|
import Table from '@/ui/v2/Table';
|
||||||
|
import TableBody from '@/ui/v2/TableBody';
|
||||||
|
import TableCell from '@/ui/v2/TableCell';
|
||||||
|
import TableContainer from '@/ui/v2/TableContainer';
|
||||||
|
import TableHead from '@/ui/v2/TableHead';
|
||||||
|
import TableRow from '@/ui/v2/TableRow';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { useGetRemoteAppRolesQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import NavLink from 'next/link';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import RolePermissionEditorForm from './RolePermissionEditorForm';
|
||||||
|
import RolePermissionsRow from './RolePermissionsRow';
|
||||||
|
|
||||||
|
export interface EditPermissionsFormProps {
|
||||||
|
/**
|
||||||
|
* Determines whether the form is disabled or not.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The schema that is being edited.
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
/**
|
||||||
|
* The table that is being edited.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
|
/**
|
||||||
|
* Function to be called when the operation is cancelled.
|
||||||
|
*/
|
||||||
|
onCancel?: VoidFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditPermissionsForm({
|
||||||
|
disabled,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
onCancel,
|
||||||
|
}: EditPermissionsFormProps) {
|
||||||
|
const [role, setRole] = useState<string>();
|
||||||
|
const [action, setAction] = useState<DatabaseAction>();
|
||||||
|
|
||||||
|
const { closeDrawerWithDirtyGuard } = useDialog();
|
||||||
|
const { currentWorkspace, currentApplication } =
|
||||||
|
useCurrentWorkspaceAndApplication();
|
||||||
|
|
||||||
|
const client = useRemoteApplicationGQLClient();
|
||||||
|
const {
|
||||||
|
data: rolesData,
|
||||||
|
loading: rolesLoading,
|
||||||
|
error: rolesError,
|
||||||
|
} = useGetRemoteAppRolesQuery({ client });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tableData,
|
||||||
|
status: tableStatus,
|
||||||
|
error: tableError,
|
||||||
|
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: metadata,
|
||||||
|
status: metadataStatus,
|
||||||
|
error: metadataError,
|
||||||
|
} = useMetadataQuery([`default.metadata`]);
|
||||||
|
|
||||||
|
if (tableStatus === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ActivityIndicator label="Loading table..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tableError) {
|
||||||
|
throw tableError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataStatus === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ActivityIndicator label="Loading table metadata..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadataError) {
|
||||||
|
throw metadataError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rolesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<ActivityIndicator label="Loading available roles..." />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rolesError) {
|
||||||
|
throw rolesError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableRoles = [
|
||||||
|
'public',
|
||||||
|
...(rolesData?.authRoles?.map(({ role: authRole }) => authRole) || []),
|
||||||
|
];
|
||||||
|
|
||||||
|
const metadataForTable = metadata?.tables?.find(
|
||||||
|
({ table: currentTable }) =>
|
||||||
|
currentTable.name === table && currentTable.schema === schema,
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableColumns =
|
||||||
|
tableData?.columns.map((column) => column.column_name) || [];
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
setRole(undefined);
|
||||||
|
setAction(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancel() {
|
||||||
|
setRole(undefined);
|
||||||
|
setAction(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccessLevel(
|
||||||
|
permission?: HasuraMetadataPermission['permission'],
|
||||||
|
): DatabaseAccessLevel {
|
||||||
|
if (
|
||||||
|
!permission ||
|
||||||
|
(!permission?.check && !permission && permission?.columns?.length === 0)
|
||||||
|
) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTableColumns = [...availableColumns].sort();
|
||||||
|
const isAllColumnSelected =
|
||||||
|
sortedTableColumns.length === permission?.columns?.length &&
|
||||||
|
[...(permission?.columns || [])]
|
||||||
|
.sort()
|
||||||
|
.every(
|
||||||
|
(permissionColumn, index) =>
|
||||||
|
permissionColumn === sortedTableColumns[index],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.keys(permission?.check || {}).length === 0 &&
|
||||||
|
Object.keys(permission?.filter || {}).length === 0 &&
|
||||||
|
isAllColumnSelected
|
||||||
|
) {
|
||||||
|
return 'full';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && action) {
|
||||||
|
const permissionsForAction = {
|
||||||
|
insert: metadataForTable?.insert_permissions,
|
||||||
|
select: metadataForTable?.select_permissions,
|
||||||
|
update: metadataForTable?.update_permissions,
|
||||||
|
delete: metadataForTable?.delete_permissions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RolePermissionEditorForm
|
||||||
|
disabled={disabled}
|
||||||
|
schema={schema}
|
||||||
|
table={table}
|
||||||
|
role={role}
|
||||||
|
action={action}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={handleCancel}
|
||||||
|
permission={
|
||||||
|
permissionsForAction[action]?.find(
|
||||||
|
({ role: currentRole }) => currentRole === role,
|
||||||
|
)?.permission
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-auto flex-col content-between overflow-hidden border-t-1 border-gray-200 bg-[#fafafa]">
|
||||||
|
<div className="flex-auto">
|
||||||
|
<section className="grid grid-flow-row gap-6 content-start overflow-y-auto p-6 bg-white border-b-1 border-gray-200">
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<Text component="h2" className="!font-bold">
|
||||||
|
Roles & Actions overview
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
Rules for each role and action can be set by clicking on the
|
||||||
|
corresponding cell.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-col gap-4 items-center justify-start">
|
||||||
|
<Text
|
||||||
|
variant="subtitle2"
|
||||||
|
className="!text-greyscaleDark grid items-center grid-flow-col gap-1"
|
||||||
|
>
|
||||||
|
full access <FullPermissionIcon />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
variant="subtitle2"
|
||||||
|
className="!text-greyscaleDark grid items-center grid-flow-col gap-1"
|
||||||
|
>
|
||||||
|
partial access <PartialPermissionIcon />
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text
|
||||||
|
variant="subtitle2"
|
||||||
|
className="!text-greyscaleDark grid items-center grid-flow-col gap-1"
|
||||||
|
>
|
||||||
|
no access <NoPermissionIcon />
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<TableHead className="block">
|
||||||
|
<TableRow className="grid grid-cols-5 items-center">
|
||||||
|
<TableCell className="border-b-0 p-2">Role</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="border-b-0 p-2 text-center">
|
||||||
|
Insert
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="border-b-0 p-2 text-center">
|
||||||
|
Select
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="border-b-0 p-2 text-center">
|
||||||
|
Update
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="border-b-0 p-2 text-center">
|
||||||
|
Delete
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
|
||||||
|
<TableBody className="rounded-sm+ block border-1">
|
||||||
|
<RolePermissionsRow
|
||||||
|
name="admin"
|
||||||
|
disabled
|
||||||
|
accessLevels={{
|
||||||
|
insert: 'full',
|
||||||
|
select: 'full',
|
||||||
|
update: 'full',
|
||||||
|
delete: 'full',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{availableRoles.map((currentRole, index) => {
|
||||||
|
const insertPermissions =
|
||||||
|
metadataForTable?.insert_permissions?.find(
|
||||||
|
({ role: permissionRole }) =>
|
||||||
|
permissionRole === currentRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectPermissions =
|
||||||
|
metadataForTable?.select_permissions?.find(
|
||||||
|
({ role: permissionRole }) =>
|
||||||
|
permissionRole === currentRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatePermissions =
|
||||||
|
metadataForTable?.update_permissions?.find(
|
||||||
|
({ role: permissionRole }) =>
|
||||||
|
permissionRole === currentRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
const deletePermissions =
|
||||||
|
metadataForTable?.delete_permissions?.find(
|
||||||
|
({ role: permissionRole }) =>
|
||||||
|
permissionRole === currentRole,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RolePermissionsRow
|
||||||
|
name={currentRole}
|
||||||
|
key={currentRole}
|
||||||
|
className={twMerge(
|
||||||
|
index === availableRoles.length - 1 && 'border-b-0',
|
||||||
|
)}
|
||||||
|
onActionSelect={(selectedAction) => {
|
||||||
|
setRole(currentRole);
|
||||||
|
setAction(selectedAction);
|
||||||
|
}}
|
||||||
|
accessLevels={{
|
||||||
|
insert: getAccessLevel(insertPermissions?.permission),
|
||||||
|
select: getAccessLevel(selectPermissions?.permission),
|
||||||
|
update: getAccessLevel(updatePermissions?.permission),
|
||||||
|
delete: getAccessLevel(deletePermissions?.permission),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
|
||||||
|
<Alert className="text-left">
|
||||||
|
Please go to the{' '}
|
||||||
|
<NavLink
|
||||||
|
href={`/${currentWorkspace.slug}/${currentApplication.slug}/settings/roles-and-permissions`}
|
||||||
|
passHref
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href="settings/roles-and-permissions"
|
||||||
|
underline="hover"
|
||||||
|
onClick={closeDrawerWithDirtyGuard}
|
||||||
|
>
|
||||||
|
Settings page
|
||||||
|
</Link>
|
||||||
|
</NavLink>{' '}
|
||||||
|
to add and delete roles.
|
||||||
|
</Alert>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-shrink-0 grid-flow-col justify-between gap-3 border-t-1 border-gray-200 p-2 bg-white">
|
||||||
|
<Button variant="borderless" color="secondary" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import Form from '@/components/common/Form';
|
||||||
|
import HighlightedText from '@/components/common/HighlightedText';
|
||||||
|
import useManagePermissionMutation from '@/hooks/dataBrowser/useManagePermissionMutation';
|
||||||
|
import type {
|
||||||
|
DatabaseAction,
|
||||||
|
HasuraMetadataPermission,
|
||||||
|
RuleGroup,
|
||||||
|
} from '@/types/dataBrowser';
|
||||||
|
import { Alert } from '@/ui/Alert';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import convertToHasuraPermissions from '@/utils/dataBrowser/convertToHasuraPermissions';
|
||||||
|
import convertToRuleGroup from '@/utils/dataBrowser/convertToRuleGroup';
|
||||||
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
|
import { yupResolver } from '@hookform/resolvers/yup';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import AggregationQuerySection from './sections/AggregationQuerySection';
|
||||||
|
import BackendOnlySection from './sections/BackendOnlySection';
|
||||||
|
import ColumnPermissionsSection from './sections/ColumnPermissionsSection';
|
||||||
|
import type { ColumnPreset } from './sections/ColumnPresetsSection';
|
||||||
|
import ColumnPresetsSection from './sections/ColumnPresetsSection';
|
||||||
|
import PermissionSettingsSection from './sections/PermissionSettingsSection';
|
||||||
|
import RootFieldPermissionsSection from './sections/RootFieldPermissionsSection';
|
||||||
|
import RowPermissionsSection from './sections/RowPermissionsSection';
|
||||||
|
import validationSchemas from './validationSchemas';
|
||||||
|
|
||||||
|
export interface RolePermissionEditorFormValues {
|
||||||
|
/**
|
||||||
|
* The permission filter to be applied for the role.
|
||||||
|
*/
|
||||||
|
filter: Record<string, any> | {};
|
||||||
|
/**
|
||||||
|
* The allowed columns to CRUD for the role.
|
||||||
|
*/
|
||||||
|
columns?: string[];
|
||||||
|
/**
|
||||||
|
* The number of rows to be returned for the role.
|
||||||
|
*/
|
||||||
|
limit?: number;
|
||||||
|
/**
|
||||||
|
* Whether the role is allowed to perform aggregations.
|
||||||
|
*/
|
||||||
|
allowAggregations?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether the role is allowed to have access to special fields.
|
||||||
|
*/
|
||||||
|
enableRootFieldCustomization?: boolean;
|
||||||
|
/**
|
||||||
|
* The allowed root fields in queries and mutations for the role.
|
||||||
|
*/
|
||||||
|
queryRootFields?: string[];
|
||||||
|
/**
|
||||||
|
* The allowed root fields in subscriptions for the role.
|
||||||
|
*/
|
||||||
|
subscriptionRootFields?: string[];
|
||||||
|
/**
|
||||||
|
* Column presets for the role.
|
||||||
|
*/
|
||||||
|
columnPresets?: ColumnPreset[];
|
||||||
|
/**
|
||||||
|
* Whether the mutation should be restricted to trusted backends.
|
||||||
|
*/
|
||||||
|
backendOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RolePermissionEditorFormProps {
|
||||||
|
/**
|
||||||
|
* Determines whether or not the form is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The schema that is being edited.
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
/**
|
||||||
|
* The table that is being edited.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
|
/**
|
||||||
|
* The role that is being edited.
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* The action that is being edited.
|
||||||
|
*/
|
||||||
|
action: DatabaseAction;
|
||||||
|
/**
|
||||||
|
* Function to be called when the form is submitted.
|
||||||
|
*/
|
||||||
|
onSubmit: VoidFunction;
|
||||||
|
/**
|
||||||
|
* Function to be called when the editing is cancelled.
|
||||||
|
*/
|
||||||
|
onCancel: VoidFunction;
|
||||||
|
/**
|
||||||
|
* The existing permissions for the role and action.
|
||||||
|
*/
|
||||||
|
permission?: HasuraMetadataPermission['permission'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultRuleGroup(
|
||||||
|
action: DatabaseAction,
|
||||||
|
permission: HasuraMetadataPermission['permission'],
|
||||||
|
): RuleGroup | {} {
|
||||||
|
if (!permission) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'insert') {
|
||||||
|
return convertToRuleGroup(permission.check);
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertToRuleGroup(permission.filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColumnPresets(data: Record<string, any>): ColumnPreset[] {
|
||||||
|
if (!data || Object.keys(data).length === 0) {
|
||||||
|
return [{ column: '', value: '' }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(data).map((key) => ({
|
||||||
|
column: key,
|
||||||
|
value: data[key],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToColumnPresetObject(
|
||||||
|
columnPresets: ColumnPreset[],
|
||||||
|
): Record<string, any> {
|
||||||
|
if (columnPresets?.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const returnValue = columnPresets.reduce((data, { column, value }) => {
|
||||||
|
if (column) {
|
||||||
|
return { ...data, [column]: value };
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
if (Object.keys(returnValue).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolePermissionEditorForm({
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
role,
|
||||||
|
action,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
permission,
|
||||||
|
disabled,
|
||||||
|
}: RolePermissionEditorFormProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
mutateAsync: managePermission,
|
||||||
|
error,
|
||||||
|
reset: resetError,
|
||||||
|
isLoading,
|
||||||
|
} = useManagePermissionMutation({
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
mutationOptions: {
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['default.metadata'] });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm<RolePermissionEditorFormValues>({
|
||||||
|
reValidateMode: 'onSubmit',
|
||||||
|
defaultValues: {
|
||||||
|
filter: getDefaultRuleGroup(action, permission),
|
||||||
|
columns: permission?.columns || [],
|
||||||
|
limit: permission?.limit || null,
|
||||||
|
allowAggregations: permission?.allow_aggregations || false,
|
||||||
|
enableRootFieldCustomization:
|
||||||
|
permission?.query_root_fields?.length > 0 ||
|
||||||
|
permission?.subscription_root_fields?.length > 0,
|
||||||
|
queryRootFields: permission?.query_root_fields || [],
|
||||||
|
subscriptionRootFields: permission?.subscription_root_fields || [],
|
||||||
|
columnPresets: getColumnPresets(permission?.set || {}),
|
||||||
|
backendOnly: permission?.backend_only || false,
|
||||||
|
},
|
||||||
|
resolver: yupResolver(validationSchemas[action]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
formState: { dirtyFields, isSubmitting },
|
||||||
|
} = form;
|
||||||
|
|
||||||
|
const { onDirtyStateChange, openDirtyConfirmation, openAlertDialog } =
|
||||||
|
useDialog();
|
||||||
|
const isDirty = Object.keys(dirtyFields).length > 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onDirtyStateChange(isDirty, 'drawer');
|
||||||
|
}, [isDirty, onDirtyStateChange]);
|
||||||
|
|
||||||
|
async function handleSubmit(values: RolePermissionEditorFormValues) {
|
||||||
|
const managePermissionPromise = managePermission({
|
||||||
|
role,
|
||||||
|
action,
|
||||||
|
mode: permission ? 'update' : 'insert',
|
||||||
|
originalPermission: permission,
|
||||||
|
permission: {
|
||||||
|
set: convertToColumnPresetObject(values.columnPresets),
|
||||||
|
columns: values.columns,
|
||||||
|
limit: values.limit,
|
||||||
|
allow_aggregations: values.allowAggregations,
|
||||||
|
query_root_fields: values.queryRootFields,
|
||||||
|
subscription_root_fields: values.subscriptionRootFields,
|
||||||
|
filter:
|
||||||
|
action !== 'insert'
|
||||||
|
? convertToHasuraPermissions(values.filter as RuleGroup)
|
||||||
|
: permission?.filter,
|
||||||
|
check:
|
||||||
|
action === 'insert'
|
||||||
|
? convertToHasuraPermissions(values.filter as RuleGroup)
|
||||||
|
: permission?.check,
|
||||||
|
backend_only: values.backendOnly,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
managePermissionPromise,
|
||||||
|
{
|
||||||
|
loading: 'Saving permission...',
|
||||||
|
success: 'Permission has been saved successfully.',
|
||||||
|
error: 'An error occurred while saving the permission.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
onDirtyStateChange(false, 'drawer');
|
||||||
|
onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCancelClick() {
|
||||||
|
if (isDirty) {
|
||||||
|
openDirtyConfirmation({
|
||||||
|
props: {
|
||||||
|
onPrimaryAction: () => {
|
||||||
|
onDirtyStateChange(false, 'drawer');
|
||||||
|
onCancel?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const deletePermissionPromise = managePermission({
|
||||||
|
role,
|
||||||
|
action,
|
||||||
|
originalPermission: permission,
|
||||||
|
mode: 'delete',
|
||||||
|
});
|
||||||
|
|
||||||
|
await toast.promise(
|
||||||
|
deletePermissionPromise,
|
||||||
|
{
|
||||||
|
loading: 'Deleting permission...',
|
||||||
|
success: 'Permission has been deleted successfully.',
|
||||||
|
error: 'An error occurred while deleting the permission.',
|
||||||
|
},
|
||||||
|
toastStyleProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
onDirtyStateChange(false, 'drawer');
|
||||||
|
onSubmit?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteClick() {
|
||||||
|
openAlertDialog({
|
||||||
|
title: 'Delete permissions',
|
||||||
|
payload: (
|
||||||
|
<span>
|
||||||
|
Are you sure you want to delete the{' '}
|
||||||
|
<HighlightedText>{action}</HighlightedText> permissions of{' '}
|
||||||
|
<HighlightedText>{role}</HighlightedText>?
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
primaryButtonText: 'Delete',
|
||||||
|
primaryButtonColor: 'error',
|
||||||
|
onPrimaryAction: handleDelete,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormProvider {...form}>
|
||||||
|
{error && error instanceof Error && (
|
||||||
|
<div className="-mt-3 mb-4 px-6">
|
||||||
|
<Alert
|
||||||
|
severity="error"
|
||||||
|
className="grid grid-flow-col items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<span className="text-left">
|
||||||
|
<strong>Error:</strong> {error.message}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
className="p-1"
|
||||||
|
onClick={resetError}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="flex flex-auto flex-col content-between overflow-hidden border-t-1 border-gray-200 bg-[#fafafa]"
|
||||||
|
>
|
||||||
|
<div className="grid grid-flow-row gap-6 content-start flex-auto py-4 overflow-auto">
|
||||||
|
<PermissionSettingsSection
|
||||||
|
title="Selected role & action"
|
||||||
|
className="justify-between grid-flow-col"
|
||||||
|
>
|
||||||
|
<div className="grid grid-flow-col gap-4">
|
||||||
|
<Text>
|
||||||
|
Role: <HighlightedText>{role}</HighlightedText>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text>
|
||||||
|
Action: <HighlightedText>{action}</HighlightedText>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="borderless" onClick={handleCancelClick}>
|
||||||
|
Change
|
||||||
|
</Button>
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
|
||||||
|
<RowPermissionsSection
|
||||||
|
disabled={disabled}
|
||||||
|
role={role}
|
||||||
|
action={action}
|
||||||
|
schema={schema}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{action !== 'delete' && (
|
||||||
|
<ColumnPermissionsSection
|
||||||
|
disabled={disabled}
|
||||||
|
role={role}
|
||||||
|
action={action}
|
||||||
|
schema={schema}
|
||||||
|
table={table}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action === 'select' && (
|
||||||
|
<>
|
||||||
|
<AggregationQuerySection role={role} disabled={disabled} />
|
||||||
|
<RootFieldPermissionsSection disabled={disabled} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(action === 'insert' || action === 'update') && (
|
||||||
|
<ColumnPresetsSection
|
||||||
|
schema={schema}
|
||||||
|
table={table}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action !== 'select' && <BackendOnlySection disabled={disabled} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid flex-shrink-0 sm:grid-flow-col sm:justify-between gap-2 border-t-1 border-gray-200 p-2 bg-white">
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
onClick={handleCancelClick}
|
||||||
|
tabIndex={isDirty ? -1 : 0}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{!disabled && (
|
||||||
|
<div className="grid grid-flow-row sm:grid-flow-col gap-2">
|
||||||
|
{Boolean(permission) && (
|
||||||
|
<Button
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
onClick={handleDeleteClick}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Delete Permissions
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
type="submit"
|
||||||
|
className="justify-self-end"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</FormProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import type { DatabaseAccessLevel, DatabaseAction } from '@/types/dataBrowser';
|
||||||
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import FullPermissionIcon from '@/ui/v2/icons/FullPermissionIcon';
|
||||||
|
import NoPermissionIcon from '@/ui/v2/icons/NoPermissionIcon';
|
||||||
|
import PartialPermissionIcon from '@/ui/v2/icons/PartialPermissionIcon';
|
||||||
|
import type { TableCellProps } from '@/ui/v2/TableCell';
|
||||||
|
import TableCell from '@/ui/v2/TableCell';
|
||||||
|
import type { TableRowProps } from '@/ui/v2/TableRow';
|
||||||
|
import TableRow from '@/ui/v2/TableRow';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export interface RolePermissionsProps extends TableRowProps {
|
||||||
|
/**
|
||||||
|
* Role name.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Determines whether or not the actions are disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Access types for specific operations.
|
||||||
|
*/
|
||||||
|
accessLevels?: Record<DatabaseAction, DatabaseAccessLevel>;
|
||||||
|
/**
|
||||||
|
* Function to be called when the user wants to open the settings for an
|
||||||
|
* operation.
|
||||||
|
*/
|
||||||
|
onActionSelect?: (action: DatabaseAction) => void;
|
||||||
|
/**
|
||||||
|
* Props passed to individual component slots.
|
||||||
|
*/
|
||||||
|
slotProps?: {
|
||||||
|
/**
|
||||||
|
* Props passed to every cell in the table row.
|
||||||
|
*/
|
||||||
|
cell?: Partial<TableCellProps>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccessLevelIcon({ level }: { level: DatabaseAccessLevel }) {
|
||||||
|
if (level === 'none') {
|
||||||
|
return <NoPermissionIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (level === 'partial') {
|
||||||
|
return <PartialPermissionIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <FullPermissionIcon />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RolePermissions({
|
||||||
|
name,
|
||||||
|
disabled,
|
||||||
|
accessLevels = {
|
||||||
|
insert: 'none',
|
||||||
|
select: 'none',
|
||||||
|
update: 'none',
|
||||||
|
delete: 'none',
|
||||||
|
},
|
||||||
|
onActionSelect,
|
||||||
|
slotProps,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: RolePermissionsProps) {
|
||||||
|
const cellProps = slotProps?.cell || {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
className={twMerge(
|
||||||
|
'grid grid-cols-5 items-center justify-items-stretch border-b-1',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TableCell
|
||||||
|
{...cellProps}
|
||||||
|
className={twMerge(
|
||||||
|
'block p-2 border-0 truncate border-r-1',
|
||||||
|
cellProps.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
{...cellProps}
|
||||||
|
className={twMerge(
|
||||||
|
'inline-grid items-center p-0 border-0 text-center w-full h-full border-r-1',
|
||||||
|
disabled && 'justify-center',
|
||||||
|
cellProps.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<AccessLevelIcon level={accessLevels.insert} />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
className="w-full h-full rounded-none"
|
||||||
|
onClick={() => onActionSelect('insert')}
|
||||||
|
>
|
||||||
|
<AccessLevelIcon level={accessLevels.insert} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
{...cellProps}
|
||||||
|
className={twMerge(
|
||||||
|
'inline-grid items-center p-0 border-0 text-center w-full h-full border-r-1',
|
||||||
|
disabled && 'justify-center',
|
||||||
|
cellProps.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<AccessLevelIcon level={accessLevels.select} />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
className="w-full h-full rounded-none"
|
||||||
|
onClick={() => onActionSelect('select')}
|
||||||
|
>
|
||||||
|
<AccessLevelIcon level={accessLevels.select} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
{...cellProps}
|
||||||
|
className={twMerge(
|
||||||
|
'inline-grid items-center p-0 border-0 text-center w-full h-full border-r-1',
|
||||||
|
disabled && 'justify-center',
|
||||||
|
cellProps.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<AccessLevelIcon level={accessLevels.update} />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
className="w-full h-full rounded-none"
|
||||||
|
onClick={() => onActionSelect('update')}
|
||||||
|
>
|
||||||
|
<AccessLevelIcon level={accessLevels.update} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell
|
||||||
|
{...cellProps}
|
||||||
|
className={twMerge(
|
||||||
|
'inline-grid items-center p-0 border-0 text-center w-full h-full',
|
||||||
|
disabled && 'justify-center',
|
||||||
|
cellProps.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{disabled ? (
|
||||||
|
<AccessLevelIcon level={accessLevels.delete} />
|
||||||
|
) : (
|
||||||
|
<IconButton
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
className="w-full h-full rounded-none"
|
||||||
|
onClick={() => onActionSelect('delete')}
|
||||||
|
>
|
||||||
|
<AccessLevelIcon level={accessLevels.delete} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './EditPermissionsForm';
|
||||||
|
export { default } from './EditPermissionsForm';
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import ControlledSwitch from '@/components/common/ControlledSwitch';
|
||||||
|
import HighlightedText from '@/components/common/HighlightedText';
|
||||||
|
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||||
|
|
||||||
|
export interface AggregationQuerySectionProps {
|
||||||
|
/**
|
||||||
|
* The role that is being edited.
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* Determines whether or not the section is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AggregationQuerySection({
|
||||||
|
role,
|
||||||
|
disabled,
|
||||||
|
}: AggregationQuerySectionProps) {
|
||||||
|
const { setValue, getValues } =
|
||||||
|
useFormContext<RolePermissionEditorFormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionSettingsSection title="Aggregation queries permissions">
|
||||||
|
<Text variant="subtitle1">
|
||||||
|
Allow queries with aggregate functions like sum, count, avg, max, min,
|
||||||
|
etc.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ControlledSwitch
|
||||||
|
disabled={disabled}
|
||||||
|
name="allowAggregations"
|
||||||
|
label={
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Allow <HighlightedText>{role}</HighlightedText> to make aggregation
|
||||||
|
queries
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (event.target.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(
|
||||||
|
'queryRootFields',
|
||||||
|
getValues('queryRootFields')?.filter(
|
||||||
|
(field) => field !== 'select_aggregate',
|
||||||
|
) || [],
|
||||||
|
);
|
||||||
|
|
||||||
|
setValue(
|
||||||
|
'subscriptionRootFields',
|
||||||
|
getValues('subscriptionRootFields')?.filter(
|
||||||
|
(field) => field !== 'select_aggregate',
|
||||||
|
) || [],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import ControlledSwitch from '@/components/common/ControlledSwitch';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||||
|
|
||||||
|
export interface BackendOnlySectionProps {
|
||||||
|
/**
|
||||||
|
* Determines whether or not the section is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BackendOnlySection({
|
||||||
|
disabled,
|
||||||
|
}: BackendOnlySectionProps) {
|
||||||
|
return (
|
||||||
|
<PermissionSettingsSection title="Backend only">
|
||||||
|
<Text variant="subtitle1">
|
||||||
|
When enabled, this mutation is accessible only via 'trusted
|
||||||
|
backends'.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ControlledSwitch
|
||||||
|
disabled={disabled}
|
||||||
|
name="backendOnly"
|
||||||
|
label={
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Allow from backends only
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import HighlightedText from '@/components/common/HighlightedText';
|
||||||
|
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
|
||||||
|
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
|
||||||
|
import type { DatabaseAction } from '@/types/dataBrowser';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Checkbox from '@/ui/v2/Checkbox';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||||
|
|
||||||
|
export interface ColumnPermissionsSectionProps {
|
||||||
|
/**
|
||||||
|
* The role that is being edited.
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* The action that is being edited.
|
||||||
|
*/
|
||||||
|
action: DatabaseAction;
|
||||||
|
/**
|
||||||
|
* The schema that is being edited.
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
/**
|
||||||
|
* The table that is being edited.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
|
/**
|
||||||
|
* Determines whether or not the section is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColumnPermissionsSection({
|
||||||
|
role,
|
||||||
|
action,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
disabled,
|
||||||
|
}: ColumnPermissionsSectionProps) {
|
||||||
|
const { register, setValue } =
|
||||||
|
useFormContext<RolePermissionEditorFormValues>();
|
||||||
|
const selectedColumns = useWatch({ name: 'columns' }) as string[];
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: tableData,
|
||||||
|
status: tableStatus,
|
||||||
|
error: tableError,
|
||||||
|
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
|
||||||
|
|
||||||
|
if (tableError) {
|
||||||
|
throw tableError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllSelected = selectedColumns?.length === tableData?.columns?.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionSettingsSection title={`Column ${action} permissions`}>
|
||||||
|
<div className="grid grid-flow-col justify-between gap-2 items-center">
|
||||||
|
<Text>
|
||||||
|
Allow role <HighlightedText>{role}</HighlightedText> to{' '}
|
||||||
|
<HighlightedText>{action}</HighlightedText> columns:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (isAllSelected) {
|
||||||
|
setValue('columns', []);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(
|
||||||
|
'columns',
|
||||||
|
tableData?.columns?.map((column) => column.column_name),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isAllSelected ? 'Deselect All' : 'Select All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tableStatus === 'loading' && (
|
||||||
|
<ActivityIndicator label="Loading columns..." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tableStatus === 'success' && (
|
||||||
|
<div className="flex flex-row gap-6 justify-start flex-wrap items-center">
|
||||||
|
{tableData?.columns?.map((column) => (
|
||||||
|
<Checkbox
|
||||||
|
disabled={disabled}
|
||||||
|
name="columns"
|
||||||
|
value={column.column_name}
|
||||||
|
label={column.column_name}
|
||||||
|
key={column.column_name}
|
||||||
|
checked={selectedColumns.includes(column.column_name)}
|
||||||
|
{...register('columns')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text variant="subtitle1">
|
||||||
|
For <strong>relationships</strong>, set permissions for the
|
||||||
|
corresponding tables/views.
|
||||||
|
</Text>
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import ControlledSelect from '@/components/common/ControlledSelect';
|
||||||
|
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
|
||||||
|
import useTableQuery from '@/hooks/dataBrowser/useTableQuery';
|
||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import Autocomplete from '@/ui/v2/Autocomplete';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||||
|
import XIcon from '@/ui/v2/icons/XIcon';
|
||||||
|
import InputLabel from '@/ui/v2/InputLabel';
|
||||||
|
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';
|
||||||
|
|
||||||
|
export interface ColumnPreset {
|
||||||
|
column: string;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnPresetSectionProps {
|
||||||
|
/**
|
||||||
|
* Schema to use for fetching available columns.
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
/**
|
||||||
|
* Table to use for fetching available columns.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
|
/**
|
||||||
|
* Determines whether or not the section is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ColumnPresetsSection({
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
disabled,
|
||||||
|
}: ColumnPresetSectionProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const {
|
||||||
|
data: tableData,
|
||||||
|
status: tableStatus,
|
||||||
|
error: tableError,
|
||||||
|
} = useTableQuery([`default.${schema}.${table}`], { schema, table });
|
||||||
|
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
|
const { data: customClaimsData } = useGetAppCustomClaimsQuery({
|
||||||
|
variables: { id: currentApplication?.id },
|
||||||
|
skip: !currentApplication?.id,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
setValue,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<RolePermissionEditorFormValues>();
|
||||||
|
const { fields, append, remove } = useFieldArray({ name: 'columnPresets' });
|
||||||
|
const columnPresets = useWatch({ name: 'columnPresets' }) as ColumnPreset[];
|
||||||
|
|
||||||
|
const allColumnNames: string[] =
|
||||||
|
tableData?.columns.map((column) => column.column_name) || [];
|
||||||
|
const selectedColumns = fields as (ColumnPreset & { id: string })[];
|
||||||
|
const selectedColumnsMap = columnPresets.reduce(
|
||||||
|
(map, { column }) => map.set(column, true),
|
||||||
|
new Map<string, boolean>(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tableError) {
|
||||||
|
throw tableError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionVariableOptions = getPermissionVariablesArray(
|
||||||
|
customClaimsData?.app?.authJwtCustomClaims,
|
||||||
|
).map(({ key }) => ({
|
||||||
|
label: `X-Hasura-${key}`,
|
||||||
|
value: `X-Hasura-${key}`,
|
||||||
|
group: 'Permission variables',
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionSettingsSection title="Column presets" className="gap-6">
|
||||||
|
<Text variant="subtitle1">
|
||||||
|
Set static values or session variables as pre-determined values for
|
||||||
|
columns while inserting.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_40px] gap-2">
|
||||||
|
<InputLabel as="span">Column Name</InputLabel>
|
||||||
|
<InputLabel as="span">Column Value</InputLabel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tableStatus === 'loading' && (
|
||||||
|
<ActivityIndicator label="Loading columns..." />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row gap-4">
|
||||||
|
{tableStatus === 'success' &&
|
||||||
|
selectedColumns.map((field, index) => (
|
||||||
|
<div
|
||||||
|
key={field.id}
|
||||||
|
className="grid grid-cols-[1fr_1fr_40px] gap-2"
|
||||||
|
>
|
||||||
|
<ControlledSelect
|
||||||
|
disabled={disabled}
|
||||||
|
name={`columnPresets.${index}.column`}
|
||||||
|
error={Boolean(
|
||||||
|
errors?.columnPresets?.at(index).column?.message,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{allColumnNames.map((column) => (
|
||||||
|
<Option
|
||||||
|
value={column}
|
||||||
|
disabled={selectedColumnsMap.has(column)}
|
||||||
|
key={column}
|
||||||
|
>
|
||||||
|
{column}
|
||||||
|
</Option>
|
||||||
|
))}
|
||||||
|
</ControlledSelect>
|
||||||
|
|
||||||
|
<Autocomplete
|
||||||
|
disabled={disabled}
|
||||||
|
options={permissionVariableOptions}
|
||||||
|
groupBy={(option) => option.group}
|
||||||
|
name={`columnPresets.${index}.value`}
|
||||||
|
inputValue={field.value}
|
||||||
|
value={field.value}
|
||||||
|
freeSolo
|
||||||
|
fullWidth
|
||||||
|
disableClearable={false}
|
||||||
|
clearIcon={
|
||||||
|
<XIcon
|
||||||
|
className="w-4 h-4 mt-px"
|
||||||
|
sx={{ color: theme.palette.text.primary }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
autoSelect
|
||||||
|
autoHighlight={false}
|
||||||
|
error={Boolean(
|
||||||
|
errors?.columnPresets?.at(index).value?.message,
|
||||||
|
)}
|
||||||
|
isOptionEqualToValue={(option, value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return (
|
||||||
|
option.value.toLowerCase() ===
|
||||||
|
(value as string).toLowerCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
option.value.toLowerCase() === value.value.toLowerCase()
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onChange={(_event, _value, reason, details) => {
|
||||||
|
if (reason === 'clear') {
|
||||||
|
setValue(`columnPresets.${index}.value`, null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(
|
||||||
|
`columnPresets.${index}.value`,
|
||||||
|
typeof details.option === 'string'
|
||||||
|
? details.option
|
||||||
|
: details.option.value,
|
||||||
|
{ shouldDirty: true },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
disabled={disabled}
|
||||||
|
variant="outlined"
|
||||||
|
color="secondary"
|
||||||
|
className="shrink-0 grow-0 flex-[40px]"
|
||||||
|
onClick={() => {
|
||||||
|
if (fields.length === 1) {
|
||||||
|
remove(index);
|
||||||
|
append({ column: '', value: '' });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4" />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
startIcon={<PlusIcon />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => append({ column: '', value: '' })}
|
||||||
|
disabled={
|
||||||
|
selectedColumns.length === allColumnNames.length || disabled
|
||||||
|
}
|
||||||
|
className="justify-self-start"
|
||||||
|
>
|
||||||
|
Add Column
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { TextProps } from '@/ui/v2/Text';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import type { DetailedHTMLProps, HTMLProps, ReactNode } from 'react';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export interface PermissionSettingsSectionProps
|
||||||
|
extends Omit<
|
||||||
|
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||||
|
'title'
|
||||||
|
> {
|
||||||
|
/**
|
||||||
|
* Title of the section.
|
||||||
|
*/
|
||||||
|
title: ReactNode;
|
||||||
|
/**
|
||||||
|
* Props to be passed to individual slots.
|
||||||
|
*/
|
||||||
|
slotProps?: {
|
||||||
|
/**
|
||||||
|
* Props to be passed to the root slot.
|
||||||
|
*/
|
||||||
|
root?: DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>;
|
||||||
|
/**
|
||||||
|
* Props to be passed to the title slot.
|
||||||
|
*/
|
||||||
|
title?: TextProps;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PermissionSettingsSection({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
slotProps,
|
||||||
|
...props
|
||||||
|
}: PermissionSettingsSectionProps) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
{...(slotProps?.root || {})}
|
||||||
|
className={twMerge(
|
||||||
|
'bg-white border-y-1 border-gray-200',
|
||||||
|
slotProps?.root?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
component="h2"
|
||||||
|
{...(slotProps?.title || {})}
|
||||||
|
className={twMerge(
|
||||||
|
'px-6 py-3 font-bold border-b-1 border-gray-200',
|
||||||
|
slotProps?.title?.className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={twMerge(
|
||||||
|
'grid grid-flow-row gap-4 items-center px-6 py-4',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import ControlledSwitch from '@/components/common/ControlledSwitch';
|
||||||
|
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
|
||||||
|
import Button from '@/ui/v2/Button';
|
||||||
|
import Checkbox from '@/ui/v2/Checkbox';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||||
|
|
||||||
|
export interface RootFieldPermissionsSectionProps {
|
||||||
|
/**
|
||||||
|
* Determines whether or not the section is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RootFieldPermissionsSection({
|
||||||
|
disabled,
|
||||||
|
}: RootFieldPermissionsSectionProps) {
|
||||||
|
const { register, setValue } =
|
||||||
|
useFormContext<RolePermissionEditorFormValues>();
|
||||||
|
const allowAggregations = useWatch({
|
||||||
|
name: 'allowAggregations',
|
||||||
|
}) as boolean;
|
||||||
|
const enableRootFieldCustomization = useWatch({
|
||||||
|
name: 'enableRootFieldCustomization',
|
||||||
|
}) as boolean;
|
||||||
|
const checkedQueryRootFields = useWatch({
|
||||||
|
name: 'queryRootFields',
|
||||||
|
}) as string[];
|
||||||
|
const checkedSubscriptionRootFields = useWatch({
|
||||||
|
name: 'subscriptionRootFields',
|
||||||
|
}) as string[];
|
||||||
|
|
||||||
|
const numberOfAvailableQueryRootFields = allowAggregations ? 3 : 2;
|
||||||
|
const availableQueryRootFields = allowAggregations
|
||||||
|
? checkedQueryRootFields
|
||||||
|
: checkedQueryRootFields.filter((field) => field !== 'select_aggregate');
|
||||||
|
|
||||||
|
const numberOfAvailableSubscriptionRootFields = allowAggregations ? 3 : 2;
|
||||||
|
const availableSubscriptionRootFields = allowAggregations
|
||||||
|
? checkedSubscriptionRootFields
|
||||||
|
: checkedSubscriptionRootFields.filter(
|
||||||
|
(field) => field !== 'select_aggregate',
|
||||||
|
);
|
||||||
|
|
||||||
|
function toggleQueryRootFields() {
|
||||||
|
if (availableQueryRootFields.length === numberOfAvailableQueryRootFields) {
|
||||||
|
setValue('queryRootFields', []);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowAggregations) {
|
||||||
|
setValue('queryRootFields', ['select', 'select_by_pk']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('queryRootFields', ['select', 'select_by_pk', 'select_aggregate']);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSubscriptionRootFields() {
|
||||||
|
if (
|
||||||
|
availableSubscriptionRootFields.length ===
|
||||||
|
numberOfAvailableSubscriptionRootFields
|
||||||
|
) {
|
||||||
|
setValue('subscriptionRootFields', []);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowAggregations) {
|
||||||
|
setValue('subscriptionRootFields', ['select', 'select_by_pk']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('subscriptionRootFields', [
|
||||||
|
'select',
|
||||||
|
'select_by_pk',
|
||||||
|
'select_aggregate',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionSettingsSection title="Root fields permissions">
|
||||||
|
<Text variant="subtitle1">
|
||||||
|
By enabling this you can customize the root field permissions. When this
|
||||||
|
switch is turned off, all values are enabled by default.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ControlledSwitch
|
||||||
|
disabled={disabled}
|
||||||
|
name="enableRootFieldCustomization"
|
||||||
|
label={
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Enable GraphQL root field visibility customization
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!event.target.checked) {
|
||||||
|
setValue('queryRootFields', []);
|
||||||
|
setValue('subscriptionRootFields', []);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!allowAggregations) {
|
||||||
|
setValue('queryRootFields', ['select', 'select_by_pk']);
|
||||||
|
setValue('subscriptionRootFields', ['select', 'select_by_pk']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue('queryRootFields', [
|
||||||
|
'select',
|
||||||
|
'select_by_pk',
|
||||||
|
'select_aggregate',
|
||||||
|
]);
|
||||||
|
setValue('subscriptionRootFields', [
|
||||||
|
'select',
|
||||||
|
'select_by_pk',
|
||||||
|
'select_aggregate',
|
||||||
|
]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{enableRootFieldCustomization && (
|
||||||
|
<div className="grid grid-flow-row gap-4">
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<div className="grid grid-flow-row items-center sm:grid-flow-col gap-2 justify-center sm:justify-between">
|
||||||
|
<Text>
|
||||||
|
Allow the following root fields under the{' '}
|
||||||
|
<strong>query root field</strong>:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={toggleQueryRootFields}
|
||||||
|
>
|
||||||
|
{availableQueryRootFields.length ===
|
||||||
|
numberOfAvailableQueryRootFields
|
||||||
|
? 'Deselect All'
|
||||||
|
: 'Select All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row flex-wrap gap-6 justify-start">
|
||||||
|
<Checkbox
|
||||||
|
disabled={disabled}
|
||||||
|
name="queryRootFields"
|
||||||
|
value="select"
|
||||||
|
label="select"
|
||||||
|
checked={availableQueryRootFields.includes('select')}
|
||||||
|
{...register('queryRootFields')}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
disabled={disabled}
|
||||||
|
name="queryRootFields"
|
||||||
|
value="select_by_pk"
|
||||||
|
label="select_by_pk"
|
||||||
|
checked={availableQueryRootFields.includes('select_by_pk')}
|
||||||
|
{...register('queryRootFields')}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!allowAggregations || disabled}
|
||||||
|
name="queryRootFields"
|
||||||
|
value="select_aggregate"
|
||||||
|
label="select_aggregate"
|
||||||
|
checked={
|
||||||
|
allowAggregations
|
||||||
|
? availableQueryRootFields.includes('select_aggregate')
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
{...register('queryRootFields')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-flow-row gap-2">
|
||||||
|
<div className="grid grid-flow-row items-center sm:grid-flow-col gap-2 justify-center sm:justify-between">
|
||||||
|
<Text>
|
||||||
|
Allow the following root fields under the{' '}
|
||||||
|
<strong>subscription root field</strong>:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
disabled={disabled}
|
||||||
|
variant="borderless"
|
||||||
|
size="small"
|
||||||
|
onClick={toggleSubscriptionRootFields}
|
||||||
|
>
|
||||||
|
{availableSubscriptionRootFields.length ===
|
||||||
|
numberOfAvailableSubscriptionRootFields
|
||||||
|
? 'Deselect All'
|
||||||
|
: 'Select All'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-row flex-wrap gap-6 justify-start">
|
||||||
|
<Checkbox
|
||||||
|
disabled={disabled}
|
||||||
|
name="subscriptionRootFields"
|
||||||
|
value="select"
|
||||||
|
label="select"
|
||||||
|
checked={availableSubscriptionRootFields.includes('select')}
|
||||||
|
{...register('subscriptionRootFields')}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
disabled={disabled}
|
||||||
|
name="subscriptionRootFields"
|
||||||
|
value="select_by_pk"
|
||||||
|
label="select_by_pk"
|
||||||
|
checked={availableSubscriptionRootFields.includes(
|
||||||
|
'select_by_pk',
|
||||||
|
)}
|
||||||
|
{...register('subscriptionRootFields')}
|
||||||
|
/>
|
||||||
|
<Checkbox
|
||||||
|
disabled={!allowAggregations || disabled}
|
||||||
|
name="subscriptionRootFields"
|
||||||
|
value="select_aggregate"
|
||||||
|
label="select_aggregate"
|
||||||
|
checked={
|
||||||
|
allowAggregations
|
||||||
|
? availableSubscriptionRootFields.includes(
|
||||||
|
'select_aggregate',
|
||||||
|
)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
{...register('subscriptionRootFields')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import HighlightedText from '@/components/common/HighlightedText';
|
||||||
|
import type { RolePermissionEditorFormValues } from '@/components/dataBrowser/EditPermissionsForm/RolePermissionEditorForm';
|
||||||
|
import RuleGroupEditor from '@/components/dataBrowser/RuleGroupEditor';
|
||||||
|
import type { DatabaseAction, RuleGroup } from '@/types/dataBrowser';
|
||||||
|
import Input from '@/ui/v2/Input';
|
||||||
|
import Radio from '@/ui/v2/Radio';
|
||||||
|
import RadioGroup from '@/ui/v2/RadioGroup';
|
||||||
|
import Text from '@/ui/v2/Text';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useFormContext } from 'react-hook-form';
|
||||||
|
import PermissionSettingsSection from './PermissionSettingsSection';
|
||||||
|
|
||||||
|
export interface RowPermissionsSectionProps {
|
||||||
|
/**
|
||||||
|
* Determines whether or not the section is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* The role that is being edited.
|
||||||
|
*/
|
||||||
|
role: string;
|
||||||
|
/**
|
||||||
|
* The action that is being edited.
|
||||||
|
*/
|
||||||
|
action: DatabaseAction;
|
||||||
|
/**
|
||||||
|
* The schema that is being edited.
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
/**
|
||||||
|
* The table that is being edited.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RowPermissionsSection({
|
||||||
|
role,
|
||||||
|
action,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
disabled,
|
||||||
|
}: RowPermissionsSectionProps) {
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
setValue,
|
||||||
|
getValues,
|
||||||
|
formState: { errors },
|
||||||
|
} = useFormContext<RolePermissionEditorFormValues>();
|
||||||
|
const { filter } = getValues();
|
||||||
|
|
||||||
|
const defaultRowCheckType =
|
||||||
|
filter &&
|
||||||
|
'rules' in filter &&
|
||||||
|
'groups' in filter &&
|
||||||
|
(filter.rules.length > 0 ||
|
||||||
|
filter.groups.length > 0 ||
|
||||||
|
filter.unsupported?.length > 0)
|
||||||
|
? 'custom'
|
||||||
|
: 'none';
|
||||||
|
|
||||||
|
const [temporaryPermissions, setTemporaryPermissions] = useState<
|
||||||
|
RuleGroup | {}
|
||||||
|
>(null);
|
||||||
|
|
||||||
|
const [rowCheckType, setRowCheckType] = useState<'none' | 'custom'>(
|
||||||
|
filter ? defaultRowCheckType : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleCheckTypeChange(value: typeof rowCheckType) {
|
||||||
|
setRowCheckType(value);
|
||||||
|
|
||||||
|
if (value === 'none') {
|
||||||
|
setTemporaryPermissions(getValues().filter);
|
||||||
|
|
||||||
|
// Note: https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
|
||||||
|
// @ts-ignore
|
||||||
|
setValue('filter', {});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRowCheckType(value);
|
||||||
|
setValue(
|
||||||
|
'filter',
|
||||||
|
temporaryPermissions || {
|
||||||
|
operator: '_and',
|
||||||
|
rules: [{ column: '', operator: '_eq', value: '' }],
|
||||||
|
groups: [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PermissionSettingsSection title={`Row ${action} permissions`}>
|
||||||
|
<Text>
|
||||||
|
Allow role <HighlightedText>{role}</HighlightedText> to{' '}
|
||||||
|
<HighlightedText>{action}</HighlightedText> rows:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={rowCheckType}
|
||||||
|
className="grid grid-flow-col justify-start gap-4"
|
||||||
|
onChange={(_event, value) =>
|
||||||
|
handleCheckTypeChange(value as typeof rowCheckType)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Radio value="none" label="Without any checks" disabled={disabled} />
|
||||||
|
<Radio value="custom" label="With custom check" disabled={disabled} />
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{errors?.filter?.message && (
|
||||||
|
<Text variant="subtitle2" className="font-normal !text-red">
|
||||||
|
{errors.filter.message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{rowCheckType === 'custom' && (
|
||||||
|
<RuleGroupEditor
|
||||||
|
name="filter"
|
||||||
|
schema={schema}
|
||||||
|
table={table}
|
||||||
|
className="w-full overflow-x-auto"
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{action === 'select' && (
|
||||||
|
<Input
|
||||||
|
{...register('limit')}
|
||||||
|
disabled={disabled}
|
||||||
|
id="limit"
|
||||||
|
type="number"
|
||||||
|
label="Limit number of rows"
|
||||||
|
slotProps={{
|
||||||
|
input: { className: 'max-w-xs w-full' },
|
||||||
|
inputRoot: { min: 0 },
|
||||||
|
}}
|
||||||
|
helperText={
|
||||||
|
errors?.limit?.message ||
|
||||||
|
'Set limit on number of rows fetched per request.'
|
||||||
|
}
|
||||||
|
error={Boolean(errors?.limit)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</PermissionSettingsSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { DatabaseAction } from '@/types/dataBrowser';
|
||||||
|
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.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({
|
||||||
|
operator: Yup.string().test(
|
||||||
|
'operator',
|
||||||
|
'Please select an operator.',
|
||||||
|
(selectedOperator, ctx) => {
|
||||||
|
// `from` is part of the Yup API, but it's not typed.
|
||||||
|
// @ts-ignore
|
||||||
|
const [, { value }] = ctx.from;
|
||||||
|
|
||||||
|
if (Object.keys(value.filter).length > 0 && !selectedOperator) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
rules: Yup.array().of(ruleSchema),
|
||||||
|
groups: Yup.array().of(Yup.lazy(() => ruleGroupSchema) as any),
|
||||||
|
});
|
||||||
|
|
||||||
|
const baseValidationSchema = Yup.object().shape({
|
||||||
|
filter: ruleGroupSchema.nullable().required('Please select a filter type.'),
|
||||||
|
columns: Yup.array().of(Yup.string()).nullable(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectValidationSchema = baseValidationSchema.shape({
|
||||||
|
limit: Yup.number().min(0, 'Limit must not be negative.').nullable(true),
|
||||||
|
allowAggregations: Yup.boolean().nullable(true),
|
||||||
|
queryRootFields: Yup.array().of(Yup.string()).nullable(true),
|
||||||
|
subscriptionRootFields: Yup.array().of(Yup.string()).nullable(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const columnPresetSchema = Yup.object().shape({
|
||||||
|
column: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.test('column', 'Please select a column.', (selectedColumn, ctx) => {
|
||||||
|
// `from` is part of the Yup API, but it's not typed.
|
||||||
|
// @ts-ignore
|
||||||
|
const [, { value }] = ctx.from;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.columnPresets.length > 1 && !selectedColumn) ||
|
||||||
|
(!!ctx.parent.value && !selectedColumn)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
value: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.test('value', 'Please enter a value.', (selectedValue, ctx) => {
|
||||||
|
// `from` is part of the Yup API, but it's not typed.
|
||||||
|
// @ts-ignore
|
||||||
|
const [, { value }] = ctx.from;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(value.columnPresets.length > 1 && !selectedValue) ||
|
||||||
|
(!!ctx.parent.column && !selectedValue)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertValidationSchema = baseValidationSchema.shape({
|
||||||
|
backendOnly: Yup.boolean().nullable(true),
|
||||||
|
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateValidationSchema = baseValidationSchema.shape({
|
||||||
|
backendOnly: Yup.boolean().nullable(true),
|
||||||
|
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteValidationSchema = baseValidationSchema.shape({
|
||||||
|
columnPresets: Yup.array().of(columnPresetSchema).nullable(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
const validationSchemas: Record<DatabaseAction, Yup.ObjectSchema<any>> = {
|
||||||
|
select: selectValidationSchema,
|
||||||
|
insert: insertValidationSchema,
|
||||||
|
update: updateValidationSchema,
|
||||||
|
delete: deleteValidationSchema,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default validationSchemas;
|
||||||
@@ -3,13 +3,13 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
|
|||||||
import type { HasuraOperator } from '@/types/dataBrowser';
|
import type { HasuraOperator } from '@/types/dataBrowser';
|
||||||
import Option from '@/ui/v2/Option';
|
import Option from '@/ui/v2/Option';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useController, useFormContext } from 'react-hook-form';
|
import { useController, useFormContext } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import RuleRemoveButton from './RuleRemoveButton';
|
import RuleRemoveButton from './RuleRemoveButton';
|
||||||
import RuleValueInput from './RuleValueInput';
|
import RuleValueInput from './RuleValueInput';
|
||||||
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
|
|
||||||
export interface RuleEditorRowProps
|
export interface RuleEditorRowProps
|
||||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
||||||
@@ -98,12 +98,14 @@ export default function RuleEditorRow({
|
|||||||
disabledOperators = [],
|
disabledOperators = [],
|
||||||
...props
|
...props
|
||||||
}: RuleEditorRowProps) {
|
}: RuleEditorRowProps) {
|
||||||
const {
|
const { schema, table, disabled } = useRuleGroupEditor();
|
||||||
query: { schemaSlug, tableSlug },
|
const { control, setValue, getFieldState } = useFormContext();
|
||||||
} = useRouter();
|
|
||||||
const { control, setValue } = useFormContext();
|
|
||||||
const rowName = `${name}.rules.${index}`;
|
const rowName = `${name}.rules.${index}`;
|
||||||
|
|
||||||
|
const columnState = getFieldState(`${rowName}.column`);
|
||||||
|
const operatorState = getFieldState(`${rowName}.operator`);
|
||||||
|
const valueState = getFieldState(`${rowName}.value`);
|
||||||
|
|
||||||
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
const [selectedTablePath, setSelectedTablePath] = useState<string>('');
|
||||||
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
const [selectedColumnType, setSelectedColumnType] = useState<string>('');
|
||||||
const { field: autocompleteField } = useController({
|
const { field: autocompleteField } = useController({
|
||||||
@@ -128,30 +130,36 @@ export default function RuleEditorRow({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'flex lg:flex-row flex-col items-stretch lg:max-h-10 flex-1 space-y-1 lg:space-y-0',
|
'grid lg:grid-cols-[320px_140px_minmax(100px,_1fr)_40px] grid-flow-row lg:max-h-10 space-y-1 lg:space-y-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ColumnAutocomplete
|
<ColumnAutocomplete
|
||||||
{...autocompleteField}
|
{...autocompleteField}
|
||||||
schema={schemaSlug as string}
|
disabled={disabled}
|
||||||
table={tableSlug as string}
|
schema={schema}
|
||||||
rootClassName="lg:flex-grow-0 lg:flex-shrink-0 lg:flex-[320px] h-10"
|
table={table}
|
||||||
|
rootClassName="h-10"
|
||||||
slotProps={{ input: { className: 'bg-white lg:!rounded-r-none' } }}
|
slotProps={{ input: { className: 'bg-white lg:!rounded-r-none' } }}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
error={Boolean(columnState?.error?.message)}
|
||||||
onChange={(_event, { value, columnMetadata, disableReset }) => {
|
onChange={(_event, { value, columnMetadata, disableReset }) => {
|
||||||
setSelectedTablePath(
|
setSelectedTablePath(
|
||||||
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
||||||
);
|
);
|
||||||
setSelectedColumnType(columnMetadata?.udt_name);
|
setSelectedColumnType(columnMetadata?.udt_name);
|
||||||
setValue(`${rowName}.column`, value, { shouldDirty: true });
|
setValue(`${rowName}.column`, value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (disableReset) {
|
if (disableReset) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setValue(`${rowName}.operator`, '_eq', { shouldDirty: true });
|
setValue(`${rowName}.operator`, '_eq', {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
setValue(`${rowName}.value`, '', { shouldDirty: true });
|
setValue(`${rowName}.value`, '', { shouldDirty: true });
|
||||||
}}
|
}}
|
||||||
onInitialized={({ value, columnMetadata }) => {
|
onInitialized={({ value, columnMetadata }) => {
|
||||||
@@ -159,22 +167,32 @@ export default function RuleEditorRow({
|
|||||||
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
`${columnMetadata.table_schema}.${columnMetadata.table_name}`,
|
||||||
);
|
);
|
||||||
setSelectedColumnType(columnMetadata?.udt_name);
|
setSelectedColumnType(columnMetadata?.udt_name);
|
||||||
setValue(`${rowName}.column`, value, { shouldDirty: true });
|
setValue(`${rowName}.column`, value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ControlledSelect
|
<ControlledSelect
|
||||||
|
disabled={disabled}
|
||||||
name={`${rowName}.operator`}
|
name={`${rowName}.operator`}
|
||||||
className="lg:flex-grow-0 lg:flex-shrink-0 lg:flex-[140px] h-10"
|
className="h-10"
|
||||||
slotProps={{ root: { className: 'bg-white lg:!rounded-none' } }}
|
slotProps={{
|
||||||
|
root: { className: 'bg-white lg:!rounded-none' },
|
||||||
|
listbox: { className: 'max-h-[300px]' },
|
||||||
|
popper: { disablePortal: false, className: 'z-[10000]' },
|
||||||
|
}}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
error={Boolean(operatorState?.error?.message)}
|
||||||
onChange={(_event, value: HasuraOperator) => {
|
onChange={(_event, value: HasuraOperator) => {
|
||||||
if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) {
|
if (!['_in', '_nin', '_in_hasura', '_nin_hasura'].includes(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === '_in_hasura' || value === '_nin_hasura') {
|
if (value === '_in_hasura' || value === '_nin_hasura') {
|
||||||
setValue(`${rowName}.value`, null, { shouldDirty: true });
|
setValue(`${rowName}.value`, null, {
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,9 +218,13 @@ export default function RuleEditorRow({
|
|||||||
{availableOperators.map(renderOption)}
|
{availableOperators.map(renderOption)}
|
||||||
</ControlledSelect>
|
</ControlledSelect>
|
||||||
|
|
||||||
<RuleValueInput selectedTablePath={selectedTablePath} name={rowName} />
|
<RuleValueInput
|
||||||
|
selectedTablePath={selectedTablePath}
|
||||||
|
name={rowName}
|
||||||
|
error={Boolean(valueState?.error?.message)}
|
||||||
|
/>
|
||||||
|
|
||||||
<RuleRemoveButton onRemove={onRemove} name={name} />
|
<RuleRemoveButton onRemove={onRemove} name={name} disabled={disabled} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Text from '@/ui/v2/Text';
|
|||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
import { useWatch } from 'react-hook-form';
|
import { useWatch } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
|
|
||||||
export interface RuleGroupControlsProps
|
export interface RuleGroupControlsProps
|
||||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
||||||
@@ -30,6 +31,7 @@ export default function RuleGroupControls({
|
|||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: RuleGroupControlsProps) {
|
}: RuleGroupControlsProps) {
|
||||||
|
const { disabled } = useRuleGroupEditor();
|
||||||
const currentOperator: RuleGroup['operator'] = useWatch({
|
const currentOperator: RuleGroup['operator'] = useWatch({
|
||||||
name: `${name}.operator`,
|
name: `${name}.operator`,
|
||||||
});
|
});
|
||||||
@@ -41,6 +43,7 @@ export default function RuleGroupControls({
|
|||||||
>
|
>
|
||||||
{showSelect ? (
|
{showSelect ? (
|
||||||
<ControlledSelect
|
<ControlledSelect
|
||||||
|
disabled={disabled}
|
||||||
name={`${name}.operator`}
|
name={`${name}.operator`}
|
||||||
slotProps={{ root: { className: 'bg-white' } }}
|
slotProps={{ root: { className: 'bg-white' } }}
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -65,7 +65,13 @@ const Template: ComponentStory<typeof RuleGroupEditor> = function Template(
|
|||||||
<div className="grid grid-flow-row gap-2">
|
<div className="grid grid-flow-row gap-2">
|
||||||
<FormProvider {...form}>
|
<FormProvider {...form}>
|
||||||
<Form onSubmit={handleSubmit} className="grid grid-flow-row gap-2">
|
<Form onSubmit={handleSubmit} className="grid grid-flow-row gap-2">
|
||||||
<RuleGroupEditor {...args} name="ruleGroupEditor" onRemove={null} />
|
<RuleGroupEditor
|
||||||
|
schema="public"
|
||||||
|
table="books"
|
||||||
|
{...args}
|
||||||
|
name="ruleGroupEditor"
|
||||||
|
onRemove={null}
|
||||||
|
/>
|
||||||
|
|
||||||
<Button type="submit" className="justify-self-start">
|
<Button type="submit" className="justify-self-start">
|
||||||
Submit
|
Submit
|
||||||
|
|||||||
@@ -1,17 +1,35 @@
|
|||||||
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
import type { Rule, RuleGroup } from '@/types/dataBrowser';
|
import type { Rule, RuleGroup } from '@/types/dataBrowser';
|
||||||
|
import { Alert } from '@/ui/Alert';
|
||||||
import Button from '@/ui/v2/Button';
|
import Button from '@/ui/v2/Button';
|
||||||
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
import PlusIcon from '@/ui/v2/icons/PlusIcon';
|
||||||
|
import Link from '@/ui/v2/Link';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
|
import generateAppServiceUrl from '@/utils/common/generateAppServiceUrl';
|
||||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
import type { RuleEditorRowProps } from './RuleEditorRow';
|
import type { RuleEditorRowProps } from './RuleEditorRow';
|
||||||
import RuleEditorRow from './RuleEditorRow';
|
import RuleEditorRow from './RuleEditorRow';
|
||||||
import RuleGroupControls from './RuleGroupControls';
|
import RuleGroupControls from './RuleGroupControls';
|
||||||
|
import { RuleGroupEditorContext } from './useRuleGroupEditor';
|
||||||
|
|
||||||
export interface RuleGroupEditorProps
|
export interface RuleGroupEditorProps
|
||||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||||
Pick<RuleEditorRowProps, 'disabledOperators'> {
|
Pick<RuleEditorRowProps, 'disabledOperators'> {
|
||||||
|
/**
|
||||||
|
* Determines whether or not the rule group editor is disabled.
|
||||||
|
*/
|
||||||
|
disabled?: boolean;
|
||||||
|
/**
|
||||||
|
* Schema for the column autocomplete.
|
||||||
|
*/
|
||||||
|
schema: string;
|
||||||
|
/**
|
||||||
|
* Table for the column autocomplete.
|
||||||
|
*/
|
||||||
|
table: string;
|
||||||
/**
|
/**
|
||||||
* Name of the group editor.
|
* Name of the group editor.
|
||||||
*/
|
*/
|
||||||
@@ -46,14 +64,15 @@ export default function RuleGroupEditor({
|
|||||||
disabledOperators = [],
|
disabledOperators = [],
|
||||||
depth = 0,
|
depth = 0,
|
||||||
maxDepth = 7,
|
maxDepth = 7,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
disabled,
|
||||||
...props
|
...props
|
||||||
}: RuleGroupEditorProps) {
|
}: RuleGroupEditorProps) {
|
||||||
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const form = useFormContext();
|
const form = useFormContext();
|
||||||
|
|
||||||
const { control } = form;
|
const { control, getValues } = form;
|
||||||
|
|
||||||
// Note: Reason for the type cast to `never`
|
|
||||||
// https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
|
|
||||||
const {
|
const {
|
||||||
fields: rules,
|
fields: rules,
|
||||||
append: appendRule,
|
append: appendRule,
|
||||||
@@ -61,10 +80,11 @@ export default function RuleGroupEditor({
|
|||||||
} = useFieldArray({
|
} = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: `${name}.rules`,
|
name: `${name}.rules`,
|
||||||
} as never);
|
});
|
||||||
|
|
||||||
|
const unsupportedValues: Record<string, any>[] =
|
||||||
|
getValues(`${name}.unsupported`) || [];
|
||||||
|
|
||||||
// Note: Reason for the type cast to `never`
|
|
||||||
// https://github.com/react-hook-form/react-hook-form/issues/4055#issuecomment-950145092
|
|
||||||
const {
|
const {
|
||||||
fields: groups,
|
fields: groups,
|
||||||
append: appendGroup,
|
append: appendGroup,
|
||||||
@@ -72,122 +92,164 @@ export default function RuleGroupEditor({
|
|||||||
} = useFieldArray({
|
} = useFieldArray({
|
||||||
control,
|
control,
|
||||||
name: `${name}.groups`,
|
name: `${name}.groups`,
|
||||||
} as never);
|
});
|
||||||
|
|
||||||
if (!form) {
|
if (!form) {
|
||||||
throw new Error('RuleGroupEditor must be used in a FormContext.');
|
throw new Error('RuleGroupEditor must be used in a FormContext.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
disabled,
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
}),
|
||||||
|
[disabled, schema, table],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<RuleGroupEditorContext.Provider value={contextValue}>
|
||||||
className={twMerge(
|
<div
|
||||||
'rounded-lg px-2',
|
className={twMerge(
|
||||||
depth === 0 && 'bg-greyscale-50',
|
'rounded-lg border border-r-8 border-transparent pl-2',
|
||||||
depth === 1 && 'bg-greyscale-100',
|
depth === 0 && 'bg-greyscale-50',
|
||||||
depth === 2 && 'bg-greyscale-200',
|
depth === 1 && 'bg-greyscale-100',
|
||||||
depth === 3 && 'bg-greyscale-300',
|
depth === 2 && 'bg-greyscale-200',
|
||||||
depth === 4 && 'bg-greyscale-400',
|
depth === 3 && 'bg-greyscale-300',
|
||||||
depth === 5 && 'bg-greyscale-500',
|
depth === 4 && 'bg-greyscale-400',
|
||||||
depth >= 6 && 'bg-greyscale-600',
|
depth === 5 && 'bg-greyscale-500',
|
||||||
className,
|
depth >= 6 && 'bg-greyscale-600',
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
>
|
{...props}
|
||||||
<div className="flex flex-col flex-auto space-y-4 lg:space-y-2 py-4">
|
>
|
||||||
{(rules as (Rule & { id: string })[]).map((rule, ruleIndex) => (
|
<div className="grid grid-flow-row gap-4 lg:gap-2 py-4">
|
||||||
<div className="flex flex-row flex-auto" key={rule.id}>
|
{(rules as (Rule & { id: string })[]).map((rule, ruleIndex) => (
|
||||||
<div className="flex-[70px] flex-grow-0 flex-shrink-0 mr-2">
|
<div className="grid grid-cols-[70px_1fr] gap-2" key={rule.id}>
|
||||||
{ruleIndex === 0 && (
|
<div>
|
||||||
<Text className="p-2 !font-medium">Where</Text>
|
{ruleIndex === 0 && (
|
||||||
)}
|
|
||||||
|
|
||||||
{ruleIndex > 0 && (
|
|
||||||
<RuleGroupControls name={name} showSelect={ruleIndex === 1} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RuleEditorRow
|
|
||||||
name={name}
|
|
||||||
index={ruleIndex}
|
|
||||||
onRemove={() => removeRule(ruleIndex)}
|
|
||||||
className="flex-auto"
|
|
||||||
disabledOperators={disabledOperators}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(groups as (RuleGroup & { id: string })[]).map(
|
|
||||||
(ruleGroup, ruleGroupIndex) => (
|
|
||||||
<div
|
|
||||||
className="flex flex-row flex-auto items-start mt-2"
|
|
||||||
key={ruleGroup.id}
|
|
||||||
>
|
|
||||||
<div className="flex-[70px] flex-grow-0 flex-shrink-0 mr-2">
|
|
||||||
{rules.length === 0 && ruleGroupIndex === 0 && (
|
|
||||||
<Text className="p-2 !font-medium">Where</Text>
|
<Text className="p-2 !font-medium">Where</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<RuleGroupControls
|
{ruleIndex > 0 && (
|
||||||
name={name}
|
<RuleGroupControls name={name} showSelect={ruleIndex === 1} />
|
||||||
showSelect={
|
)}
|
||||||
(rules.length === 0 && ruleGroupIndex === 1) ||
|
|
||||||
(rules.length === 1 && ruleGroupIndex === 0)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RuleGroupEditor
|
<RuleEditorRow
|
||||||
onRemove={() => removeGroup(ruleGroupIndex)}
|
name={name}
|
||||||
disableRemove={rules.length === 0 && groups.length === 1}
|
index={ruleIndex}
|
||||||
|
onRemove={() => removeRule(ruleIndex)}
|
||||||
disabledOperators={disabledOperators}
|
disabledOperators={disabledOperators}
|
||||||
name={`${name}.groups.${ruleGroupIndex}`}
|
|
||||||
className="flex-auto"
|
|
||||||
depth={depth + 1}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-flow-row lg:grid-flow-col lg:justify-between gap-2 pb-2">
|
{(groups as (RuleGroup & { id: string })[]).map(
|
||||||
<div className="grid grid-flow-row lg:grid-flow-col gap-2 lg:justify-start">
|
(ruleGroup, ruleGroupIndex) => (
|
||||||
<Button
|
<div
|
||||||
startIcon={<PlusIcon />}
|
className="grid grid-cols-[70px_1fr] gap-2"
|
||||||
variant="borderless"
|
key={ruleGroup.id}
|
||||||
onClick={() =>
|
>
|
||||||
appendRule({ column: '', operator: '_eq', value: '' })
|
<div>
|
||||||
}
|
{rules.length === 0 && ruleGroupIndex === 0 && (
|
||||||
>
|
<Text className="p-2 !font-medium">Where</Text>
|
||||||
New Rule
|
)}
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<RuleGroupControls
|
||||||
startIcon={<PlusIcon />}
|
name={name}
|
||||||
variant="borderless"
|
showSelect={
|
||||||
onClick={() =>
|
(rules.length === 0 && ruleGroupIndex === 1) ||
|
||||||
appendGroup({
|
(rules.length === 1 && ruleGroupIndex === 0)
|
||||||
operator: '_and',
|
}
|
||||||
rules: [{ column: '', operator: '_eq', value: '' }],
|
/>
|
||||||
groups: [],
|
</div>
|
||||||
})
|
|
||||||
}
|
<RuleGroupEditor
|
||||||
disabled={depth >= maxDepth - 1}
|
schema={schema}
|
||||||
>
|
table={table}
|
||||||
New Group
|
onRemove={() => removeGroup(ruleGroupIndex)}
|
||||||
</Button>
|
disableRemove={rules.length === 0 && groups.length === 1}
|
||||||
|
disabledOperators={disabledOperators}
|
||||||
|
name={`${name}.groups.${ruleGroupIndex}`}
|
||||||
|
depth={depth + 1}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
|
||||||
|
{unsupportedValues?.length > 0 && (
|
||||||
|
<Alert severity="warning" className="text-left">
|
||||||
|
<Text>
|
||||||
|
This rule group contains one or more objects (e.g: _exists) that
|
||||||
|
are not supported by our dashboard yet.{' '}
|
||||||
|
{currentApplication && (
|
||||||
|
<span>
|
||||||
|
Please{' '}
|
||||||
|
<Link
|
||||||
|
href={`${generateAppServiceUrl(
|
||||||
|
currentApplication.subdomain,
|
||||||
|
currentApplication.region?.awsName,
|
||||||
|
'hasura',
|
||||||
|
)}/console/data/default/schema/${schema}/tables/${table}/permissions`}
|
||||||
|
underline="hover"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
visit Hasura
|
||||||
|
</Link>{' '}
|
||||||
|
to edit them.
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onRemove && (
|
{!disabled && (
|
||||||
<Button
|
<div className="grid grid-flow-row lg:grid-flow-col lg:justify-between gap-2 pb-2">
|
||||||
variant="borderless"
|
<div className="grid grid-flow-row lg:grid-flow-col gap-2 lg:justify-start">
|
||||||
color="secondary"
|
<Button
|
||||||
onClick={onRemove}
|
startIcon={<PlusIcon />}
|
||||||
disabled={disableRemove}
|
variant="borderless"
|
||||||
>
|
onClick={() =>
|
||||||
Delete Group
|
appendRule({ column: '', operator: '_eq', value: '' })
|
||||||
</Button>
|
}
|
||||||
|
>
|
||||||
|
New Rule
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
startIcon={<PlusIcon />}
|
||||||
|
variant="borderless"
|
||||||
|
onClick={() =>
|
||||||
|
appendGroup({
|
||||||
|
operator: '_and',
|
||||||
|
rules: [{ column: '', operator: '_eq', value: '' }],
|
||||||
|
groups: [],
|
||||||
|
unsupported: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={depth >= maxDepth - 1}
|
||||||
|
>
|
||||||
|
New Group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onRemove && (
|
||||||
|
<Button
|
||||||
|
variant="borderless"
|
||||||
|
color="secondary"
|
||||||
|
onClick={onRemove}
|
||||||
|
disabled={disableRemove}
|
||||||
|
>
|
||||||
|
Delete Group
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</RuleGroupEditorContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,19 @@ function RuleRemoveButton({
|
|||||||
}: RuleRemoveButtonProps) {
|
}: RuleRemoveButtonProps) {
|
||||||
const rules: Rule[] = useWatch({ name: `${name}.rules` });
|
const rules: Rule[] = useWatch({ name: `${name}.rules` });
|
||||||
const groups: RuleGroup[] = useWatch({ name: `${name}.groups` });
|
const groups: RuleGroup[] = useWatch({ name: `${name}.groups` });
|
||||||
|
const unsupported: Record<string, any>[] = useWatch({
|
||||||
|
name: `${name}.unsupported`,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'!bg-white lg:!rounded-l-none lg:flex-grow-0 lg:flex-shrink-0 lg:flex-[40px] !min-w-0 h-10',
|
'!bg-white lg:!rounded-l-none !min-w-0 h-10',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
disabled={rules.length === 1 && groups.length === 0}
|
disabled={rules.length === 1 && !groups?.length && !unsupported?.length}
|
||||||
aria-label="Remove Rule"
|
aria-label="Remove Rule"
|
||||||
{...props}
|
{...props}
|
||||||
onClick={onRemove}
|
onClick={onRemove}
|
||||||
|
|||||||
@@ -6,22 +6,12 @@ import ColumnAutocomplete from '@/components/dataBrowser/ColumnAutocomplete';
|
|||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
import type { HasuraOperator } from '@/types/dataBrowser';
|
import type { HasuraOperator } from '@/types/dataBrowser';
|
||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
|
import type { InputProps } from '@/ui/v2/Input';
|
||||||
import Option from '@/ui/v2/Option';
|
import Option from '@/ui/v2/Option';
|
||||||
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
import getPermissionVariablesArray from '@/utils/settings/getPermissionVariablesArray';
|
||||||
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
import { useGetAppCustomClaimsQuery } from '@/utils/__generated__/graphql';
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
import { useController, useFormContext, useWatch } from 'react-hook-form';
|
||||||
|
import useRuleGroupEditor from './useRuleGroupEditor';
|
||||||
export interface RuleValueInputProps {
|
|
||||||
/**
|
|
||||||
* Name of the parent group editor.
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
/**
|
|
||||||
* Path of the table selected through the column input.
|
|
||||||
*/
|
|
||||||
selectedTablePath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ColumnSelectorInput({
|
function ColumnSelectorInput({
|
||||||
name,
|
name,
|
||||||
@@ -47,7 +37,6 @@ function ColumnSelectorInput({
|
|||||||
schema={schema}
|
schema={schema}
|
||||||
table={table}
|
table={table}
|
||||||
disableRelationships
|
disableRelationships
|
||||||
rootClassName="flex-auto"
|
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: { className: 'lg:!rounded-none !bg-white !z-10' },
|
input: { className: 'lg:!rounded-none !bg-white !z-10' },
|
||||||
}}
|
}}
|
||||||
@@ -64,31 +53,59 @@ function ColumnSelectorInput({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RuleValueInputProps {
|
||||||
|
/**
|
||||||
|
* Name of the parent group editor.
|
||||||
|
*/
|
||||||
|
name: string;
|
||||||
|
/**
|
||||||
|
* Path of the table selected through the column input.
|
||||||
|
*/
|
||||||
|
selectedTablePath?: string;
|
||||||
|
/**
|
||||||
|
* Whether the input should be marked as invalid.
|
||||||
|
*/
|
||||||
|
error?: InputProps['error'];
|
||||||
|
/**
|
||||||
|
* Helper text to display below the input.
|
||||||
|
*/
|
||||||
|
helperText?: InputProps['helperText'];
|
||||||
|
}
|
||||||
|
|
||||||
export default function RuleValueInput({
|
export default function RuleValueInput({
|
||||||
name,
|
name,
|
||||||
selectedTablePath,
|
selectedTablePath,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
}: RuleValueInputProps) {
|
}: RuleValueInputProps) {
|
||||||
|
const { schema, table, disabled } = useRuleGroupEditor();
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const { setValue } = useFormContext();
|
const { setValue } = useFormContext();
|
||||||
const inputName = `${name}.value`;
|
const inputName = `${name}.value`;
|
||||||
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
const operator: HasuraOperator = useWatch({ name: `${name}.operator` });
|
||||||
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
|
const isHasuraInput = operator === '_in_hasura' || operator === '_nin_hasura';
|
||||||
const {
|
|
||||||
query: { schemaSlug, tableSlug },
|
|
||||||
} = useRouter();
|
|
||||||
|
|
||||||
const { data, loading, error } = useGetAppCustomClaimsQuery({
|
const {
|
||||||
|
data,
|
||||||
|
loading,
|
||||||
|
error: customClaimsError,
|
||||||
|
} = useGetAppCustomClaimsQuery({
|
||||||
variables: { id: currentApplication?.id },
|
variables: { id: currentApplication?.id },
|
||||||
skip: !isHasuraInput,
|
skip: !isHasuraInput || !currentApplication?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (operator === '_is_null') {
|
if (operator === '_is_null') {
|
||||||
return (
|
return (
|
||||||
<ControlledSelect
|
<ControlledSelect
|
||||||
|
disabled={disabled}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
className="flex-auto"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{ root: { className: 'bg-white lg:!rounded-none h-10' } }}
|
slotProps={{
|
||||||
|
root: { className: 'bg-white lg:!rounded-none h-10' },
|
||||||
|
popper: { disablePortal: false, className: 'z-[10000]' },
|
||||||
|
}}
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
>
|
>
|
||||||
<Option value="true">
|
<Option value="true">
|
||||||
<ReadOnlyToggle
|
<ReadOnlyToggle
|
||||||
@@ -110,15 +127,17 @@ export default function RuleValueInput({
|
|||||||
if (operator === '_in' || operator === '_nin') {
|
if (operator === '_in' || operator === '_nin') {
|
||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<ControlledAutocomplete
|
||||||
|
disabled={disabled}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
multiple
|
multiple
|
||||||
freeSolo
|
freeSolo
|
||||||
limitTags={5}
|
limitTags={3}
|
||||||
className="flex-auto"
|
|
||||||
slotProps={{ input: { className: 'lg:!rounded-none !bg-white !z-10' } }}
|
slotProps={{ input: { className: 'lg:!rounded-none !bg-white !z-10' } }}
|
||||||
options={[]}
|
options={[]}
|
||||||
fullWidth
|
fullWidth
|
||||||
filterSelectedOptions
|
filterSelectedOptions
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -126,28 +145,41 @@ export default function RuleValueInput({
|
|||||||
if (['_ceq', '_cne', '_cgt', '_clt', '_cgte', '_clte'].includes(operator)) {
|
if (['_ceq', '_cne', '_cgt', '_clt', '_cgte', '_clte'].includes(operator)) {
|
||||||
return (
|
return (
|
||||||
<ColumnSelectorInput
|
<ColumnSelectorInput
|
||||||
|
disabled={disabled}
|
||||||
selectedTablePath={selectedTablePath}
|
selectedTablePath={selectedTablePath}
|
||||||
schema={schemaSlug as string}
|
schema={schema}
|
||||||
table={tableSlug as string}
|
table={table}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
|
error={error}
|
||||||
|
helperText={helperText}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const availableHasuraPermissionVariables = !loading
|
const availableHasuraPermissionVariables = getPermissionVariablesArray(
|
||||||
? getPermissionVariablesArray(data?.app?.authJwtCustomClaims).map(
|
data?.app?.authJwtCustomClaims,
|
||||||
({ key }) => ({
|
).map(({ key }) => ({
|
||||||
value: `X-Hasura-${key}`,
|
value: `X-Hasura-${key}`,
|
||||||
label: `X-Hasura-${key}`,
|
label: `X-Hasura-${key}`,
|
||||||
}),
|
group: 'Frequently used',
|
||||||
)
|
}));
|
||||||
: [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ControlledAutocomplete
|
<ControlledAutocomplete
|
||||||
|
disabled={disabled}
|
||||||
freeSolo={!isHasuraInput}
|
freeSolo={!isHasuraInput}
|
||||||
|
autoSelect={!isHasuraInput}
|
||||||
|
autoHighlight={isHasuraInput}
|
||||||
|
filterSelectedOptions
|
||||||
|
isOptionEqualToValue={(option, value) => {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
return option.value.toLowerCase() === (value as string).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
return option.value.toLowerCase() === value.value.toLowerCase();
|
||||||
|
}}
|
||||||
name={inputName}
|
name={inputName}
|
||||||
className="flex-auto"
|
groupBy={(option) => option.group}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: { className: 'lg:!rounded-none !bg-white' },
|
input: { className: 'lg:!rounded-none !bg-white' },
|
||||||
formControl: { className: '!bg-transparent' },
|
formControl: { className: '!bg-transparent' },
|
||||||
@@ -155,12 +187,18 @@ export default function RuleValueInput({
|
|||||||
fullWidth
|
fullWidth
|
||||||
loading={loading}
|
loading={loading}
|
||||||
loadingText={<ActivityIndicator label="Loading..." />}
|
loadingText={<ActivityIndicator label="Loading..." />}
|
||||||
error={!!error}
|
error={Boolean(customClaimsError) || error}
|
||||||
helperText={error?.message}
|
helperText={customClaimsError?.message || helperText}
|
||||||
options={
|
options={
|
||||||
isHasuraInput
|
isHasuraInput
|
||||||
? availableHasuraPermissionVariables
|
? availableHasuraPermissionVariables
|
||||||
: [{ value: 'X-Hasura-User-Id', label: 'X-Hasura-User-Id' }]
|
: [
|
||||||
|
{
|
||||||
|
value: 'X-Hasura-User-Id',
|
||||||
|
label: 'X-Hasura-User-Id',
|
||||||
|
group: 'Frequently used',
|
||||||
|
},
|
||||||
|
]
|
||||||
}
|
}
|
||||||
onChange={(_event, _value, reason, details) => {
|
onChange={(_event, _value, reason, details) => {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
export const RuleGroupEditorContext = createContext<{
|
||||||
|
schema: string;
|
||||||
|
table: string;
|
||||||
|
disabled: boolean;
|
||||||
|
}>({
|
||||||
|
schema: '',
|
||||||
|
table: '',
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function useRuleGroupEditor() {
|
||||||
|
const context = useContext(RuleGroupEditorContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useRuleGroupEditor must be used within a RuleGroupEditor');
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './AppDeploymentDuration';
|
||||||
|
export { default } from './AppDeploymentDuration';
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './DeploymentListItem';
|
||||||
|
export { default } from './DeploymentListItem';
|
||||||
@@ -37,7 +37,7 @@ export default function FilesDataGrid(props: FilesDataGridProps) {
|
|||||||
parseInt(router.query.page as string, 10) - 1 || 0,
|
parseInt(router.query.page as string, 10) - 1 || 0,
|
||||||
);
|
);
|
||||||
const [sortBy, setSortBy] = useState<SortingRule<StoredFile>[]>();
|
const [sortBy, setSortBy] = useState<SortingRule<StoredFile>[]>();
|
||||||
const limit = 25;
|
const limit = 10;
|
||||||
const emptyStateMessage = searchString
|
const emptyStateMessage = searchString
|
||||||
? 'No search results found.'
|
? 'No search results found.'
|
||||||
: 'No files are uploaded yet.';
|
: 'No files are uploaded yet.';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './EditWorkspaceNameForm';
|
||||||
|
export { default } from './EditWorkspaceNameForm';
|
||||||
|
|
||||||
@@ -4,11 +4,8 @@ import { InviteAnnounce } from '@/components/home/InviteAnnounce';
|
|||||||
import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
|
import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
|
||||||
import BaseLayout from '@/components/layout/BaseLayout';
|
import BaseLayout from '@/components/layout/BaseLayout';
|
||||||
import Container from '@/components/layout/Container';
|
import Container from '@/components/layout/Container';
|
||||||
import AddWorkspace from '@/components/workspace/AddWorkspace';
|
|
||||||
import { useUI } from '@/context/UIContext';
|
|
||||||
import useIsHealthy from '@/hooks/common/useIsHealthy';
|
import useIsHealthy from '@/hooks/common/useIsHealthy';
|
||||||
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
import useIsPlatform from '@/hooks/common/useIsPlatform';
|
||||||
import { Modal } from '@/ui';
|
|
||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import Link from '@/ui/v2/Link';
|
import Link from '@/ui/v2/Link';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
@@ -39,7 +36,6 @@ export default function AuthenticatedLayout({
|
|||||||
}: AuthenticatedLayoutProps) {
|
}: AuthenticatedLayoutProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
const { newWorkspace, closeSection } = useUI();
|
|
||||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||||
const isHealthy = useIsHealthy();
|
const isHealthy = useIsHealthy();
|
||||||
|
|
||||||
@@ -85,7 +81,7 @@ export default function AuthenticatedLayout({
|
|||||||
<BaseLayout {...props}>
|
<BaseLayout {...props}>
|
||||||
<Header className="flex max-h-[59px] flex-auto" />
|
<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">
|
<div className="mx-auto">
|
||||||
<Image
|
<Image
|
||||||
src="/terminal-text.svg"
|
src="/terminal-text.svg"
|
||||||
@@ -123,13 +119,7 @@ export default function AuthenticatedLayout({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout className="flex h-full flex-col" {...props}>
|
<BaseLayout className="flex flex-col h-full" {...props}>
|
||||||
<Modal
|
|
||||||
showModal={newWorkspace}
|
|
||||||
close={closeSection}
|
|
||||||
Component={AddWorkspace}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Header className="flex max-h-[59px] flex-auto" />
|
<Header className="flex max-h-[59px] flex-auto" />
|
||||||
|
|
||||||
<InviteAnnounce />
|
<InviteAnnounce />
|
||||||
|
|||||||
@@ -1,25 +1,21 @@
|
|||||||
import { AppDeploymentDuration } from '@/components/applications/AppDeployments';
|
|
||||||
import { EditRepositorySettings } from '@/components/applications/github/EditRepositorySettings';
|
import { EditRepositorySettings } from '@/components/applications/github/EditRepositorySettings';
|
||||||
import useGitHubModal from '@/components/applications/github/useGitHubModal';
|
import useGitHubModal from '@/components/applications/github/useGitHubModal';
|
||||||
import { useDialog } from '@/components/common/DialogProvider';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
import NavLink from '@/components/common/NavLink';
|
import NavLink from '@/components/common/NavLink';
|
||||||
|
import DeploymentListItem from '@/components/deployments/DeploymentListItem';
|
||||||
import GithubIcon from '@/components/icons/GithubIcon';
|
import GithubIcon from '@/components/icons/GithubIcon';
|
||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
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 ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import Button from '@/ui/v2/Button';
|
import Button from '@/ui/v2/Button';
|
||||||
import RocketIcon from '@/ui/v2/icons/RocketIcon';
|
import RocketIcon from '@/ui/v2/icons/RocketIcon';
|
||||||
import type { ListItemRootProps } from '@/ui/v2/ListItem';
|
import List from '@/ui/v2/List';
|
||||||
import { ListItem } from '@/ui/v2/ListItem';
|
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
import { getLastLiveDeployment } from '@/utils/helpers';
|
import { getLastLiveDeployment } from '@/utils/helpers';
|
||||||
import type { DeploymentRowFragment } from '@/utils/__generated__/graphql';
|
import {
|
||||||
import { useGetDeploymentsSubSubscription } from '@/utils/__generated__/graphql';
|
useGetDeploymentsSubSubscription,
|
||||||
|
useScheduledOrPendingDeploymentsSubSubscription,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
import { ChevronRightIcon } from '@heroicons/react/solid';
|
import { ChevronRightIcon } from '@heroicons/react/solid';
|
||||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
function OverviewDeploymentsTopBar() {
|
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 {
|
interface OverviewDeploymentsProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
githubRepository: { fullName: 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 (
|
return (
|
||||||
<div style={{ height: '240px' }}>
|
<div className="h-60">
|
||||||
<ActivityIndicator label="Loading deployments..." />
|
<ActivityIndicator label="Loading deployments..." />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -218,22 +137,22 @@ function OverviewDeployments({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const getLastLiveDeploymentId = getLastLiveDeployment(deployments);
|
const liveDeploymentId = getLastLiveDeployment(deployments);
|
||||||
|
const { deployments: scheduledOrPendingDeployments } =
|
||||||
|
scheduledOrPendingDeploymentsData;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-x-lg flex flex-col divide-y-1 divide-gray-200 rounded-lg border border-veryLightGray">
|
<List className="rounded-x-lg flex flex-col divide-y-1 divide-gray-200 rounded-lg border border-veryLightGray">
|
||||||
{deployments.map((deployment) => {
|
{deployments.map((deployment, index) => (
|
||||||
const isDeploymentLive = deployment.id === getLastLiveDeploymentId;
|
<DeploymentListItem
|
||||||
|
key={deployment.id}
|
||||||
return (
|
deployment={deployment}
|
||||||
<OverviewDeployment
|
isLive={deployment.id === liveDeploymentId}
|
||||||
key={deployment.id}
|
showRedeploy={index === 0}
|
||||||
deployment={deployment}
|
disableRedeploy={scheduledOrPendingDeployments.length > 0}
|
||||||
isDeploymentLive={isDeploymentLive}
|
/>
|
||||||
/>
|
))}
|
||||||
);
|
</List>
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
|
|||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import Input from '@/ui/v2/Input';
|
import Input from '@/ui/v2/Input';
|
||||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
import { useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export interface AllowedEmailSettingsFormValues {
|
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.
|
* 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() {
|
export default function AllowedEmailDomainsSettings() {
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const [updateApp] = useUpdateAppMutation();
|
const [updateApp] = useUpdateAppMutation();
|
||||||
const [enabled, setEnabled] = useState(false);
|
|
||||||
|
|
||||||
const { data, loading, error } = useGetAppQuery({
|
const { data, loading, error } = useGetAppQuery({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -36,12 +39,30 @@ export default function AllowedEmailDomainsSettings() {
|
|||||||
const form = useForm<AllowedEmailSettingsFormValues>({
|
const form = useForm<AllowedEmailSettingsFormValues>({
|
||||||
reValidateMode: 'onSubmit',
|
reValidateMode: 'onSubmit',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
enabled:
|
||||||
|
Boolean(data?.app?.authAccessControlAllowedEmails) ||
|
||||||
|
Boolean(data?.app?.authAccessControlAllowedEmailDomains),
|
||||||
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
|
authAccessControlAllowedEmails: data?.app?.authAccessControlAllowedEmails,
|
||||||
authAccessControlAllowedEmailDomains:
|
authAccessControlAllowedEmailDomains:
|
||||||
data?.app?.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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
@@ -56,8 +77,6 @@ export default function AllowedEmailDomainsSettings() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { register, formState } = form;
|
|
||||||
|
|
||||||
const handleAllowedEmailDomainsChange = async (
|
const handleAllowedEmailDomainsChange = async (
|
||||||
values: AllowedEmailSettingsFormValues,
|
values: AllowedEmailSettingsFormValues,
|
||||||
) => {
|
) => {
|
||||||
@@ -65,7 +84,12 @@ export default function AllowedEmailDomainsSettings() {
|
|||||||
variables: {
|
variables: {
|
||||||
id: currentApplication.id,
|
id: currentApplication.id,
|
||||||
app: {
|
app: {
|
||||||
...values,
|
authAccessControlAllowedEmails: values.enabled
|
||||||
|
? values.authAccessControlAllowedEmails
|
||||||
|
: '',
|
||||||
|
authAccessControlAllowedEmailDomains: values.enabled
|
||||||
|
? values.authAccessControlAllowedEmailDomains
|
||||||
|
: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -89,13 +113,17 @@ export default function AllowedEmailDomainsSettings() {
|
|||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Allowed Emails and Domains"
|
title="Allowed Emails and Domains"
|
||||||
description="Allow specific email addresses and domains to sign up."
|
description="Allow specific email addresses and domains to sign up."
|
||||||
primaryActionButtonProps={{
|
slotProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
submitButton: {
|
||||||
loading: formState.isSubmitting,
|
disabled: !formState.isValid || !isDirty,
|
||||||
|
loading: formState.isSubmitting,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
docsLink="https://docs.nhost.io/platform/authentication"
|
docsLink="https://docs.nhost.io/platform/authentication"
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
onEnabledChange={setEnabled}
|
onEnabledChange={(switchEnabled) =>
|
||||||
|
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||||
|
}
|
||||||
showSwitch
|
showSwitch
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAn
|
|||||||
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
import ActivityIndicator from '@/ui/v2/ActivityIndicator';
|
||||||
import Input from '@/ui/v2/Input';
|
import Input from '@/ui/v2/Input';
|
||||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
||||||
import { useState } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { FormProvider, useForm } from 'react-hook-form';
|
import { FormProvider, useForm } from 'react-hook-form';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export interface BlockedEmailFormValues {
|
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.
|
* Set of emails that are blocked from registering to the user's project.
|
||||||
*/
|
*/
|
||||||
@@ -24,7 +28,6 @@ export interface BlockedEmailFormValues {
|
|||||||
export default function BlockedEmailSettings() {
|
export default function BlockedEmailSettings() {
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const [updateApp] = useUpdateAppMutation();
|
const [updateApp] = useUpdateAppMutation();
|
||||||
const [enabled, setEnabled] = useState(false);
|
|
||||||
|
|
||||||
const { data, loading, error } = useGetAppQuery({
|
const { data, loading, error } = useGetAppQuery({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -35,12 +38,30 @@ export default function BlockedEmailSettings() {
|
|||||||
const form = useForm<BlockedEmailFormValues>({
|
const form = useForm<BlockedEmailFormValues>({
|
||||||
reValidateMode: 'onSubmit',
|
reValidateMode: 'onSubmit',
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
|
enabled:
|
||||||
|
Boolean(data?.app?.authAccessControlBlockedEmails) ||
|
||||||
|
Boolean(data?.app?.authAccessControlBlockedEmailDomains),
|
||||||
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
|
authAccessControlBlockedEmails: data?.app?.authAccessControlBlockedEmails,
|
||||||
authAccessControlBlockedEmailDomains:
|
authAccessControlBlockedEmailDomains:
|
||||||
data?.app?.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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<ActivityIndicator
|
<ActivityIndicator
|
||||||
@@ -55,8 +76,6 @@ export default function BlockedEmailSettings() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { register, formState } = form;
|
|
||||||
|
|
||||||
const handleAllowedEmailDomainsChange = async (
|
const handleAllowedEmailDomainsChange = async (
|
||||||
values: BlockedEmailFormValues,
|
values: BlockedEmailFormValues,
|
||||||
) => {
|
) => {
|
||||||
@@ -64,7 +83,12 @@ export default function BlockedEmailSettings() {
|
|||||||
variables: {
|
variables: {
|
||||||
id: currentApplication.id,
|
id: currentApplication.id,
|
||||||
app: {
|
app: {
|
||||||
...values,
|
authAccessControlBlockedEmails: values.enabled
|
||||||
|
? values.authAccessControlBlockedEmails
|
||||||
|
: '',
|
||||||
|
authAccessControlBlockedEmailDomains: values.enabled
|
||||||
|
? values.authAccessControlBlockedEmailDomains
|
||||||
|
: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -88,13 +112,17 @@ export default function BlockedEmailSettings() {
|
|||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Blocked Emails and Domains"
|
title="Blocked Emails and Domains"
|
||||||
description="Block specific email addresses and domains to sign up."
|
description="Block specific email addresses and domains to sign up."
|
||||||
primaryActionButtonProps={{
|
slotProps={{
|
||||||
disabled: !formState.isValid || !formState.isDirty,
|
submitButton: {
|
||||||
loading: formState.isSubmitting,
|
disabled: !formState.isValid || !isDirty,
|
||||||
|
loading: formState.isSubmitting,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
docsLink="https://docs.nhost.io/platform/authentication"
|
docsLink="https://docs.nhost.io/platform/authentication"
|
||||||
enabled={enabled}
|
enabled={enabled}
|
||||||
onEnabledChange={setEnabled}
|
onEnabledChange={(switchEnabled) =>
|
||||||
|
setValue('enabled', switchEnabled, { shouldDirty: true })
|
||||||
|
}
|
||||||
showSwitch
|
showSwitch
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
'row-span-2 grid grid-flow-row gap-4 px-4 lg:grid-cols-3',
|
||||||
|
|||||||
@@ -117,22 +117,22 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
|
description="Environment Variables are key-value pairs configured outside your source code. They are used to store environment-specific values such as API keys."
|
||||||
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
|
docsLink="https://docs.nhost.io/platform/environment-variables#system-environment-variables"
|
||||||
rootClassName="gap-0"
|
rootClassName="gap-0"
|
||||||
className="px-0 mt-2 mb-2.5"
|
className="mt-2 mb-2.5 px-0"
|
||||||
slotProps={{ submitButton: { className: 'invisible' } }}
|
slotProps={{ submitButton: { className: 'invisible' } }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-3 border-b-1 gap-2 border-gray-200 px-4 py-3">
|
<div className="grid grid-cols-3 gap-2 border-b-1 border-gray-200 px-4 py-3">
|
||||||
<Text className="font-medium">Variable Name</Text>
|
<Text className="font-medium">Variable Name</Text>
|
||||||
<Text className="font-medium lg:col-span-2">Value</Text>
|
<Text className="font-medium lg:col-span-2">Value</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<List>
|
<List>
|
||||||
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
<ListItem.Root className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3">
|
||||||
<ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text>
|
<ListItem.Text>NHOST_ADMIN_SECRET</ListItem.Text>
|
||||||
|
|
||||||
<div className="grid grid-flow-col lg:col-span-2 gap-2 items-center justify-start">
|
<div className="grid grid-flow-col items-center justify-start gap-2 lg:col-span-2">
|
||||||
<Text className="text-greyscaleGreyDark truncate">
|
<Text className="truncate text-greyscaleGreyDark">
|
||||||
{showAdminSecret ? (
|
{showAdminSecret ? (
|
||||||
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
|
<InlineCode className="!text-sm font-medium">
|
||||||
{currentApplication?.hasuraGraphqlAdminSecret}
|
{currentApplication?.hasuraGraphqlAdminSecret}
|
||||||
</InlineCode>
|
</InlineCode>
|
||||||
) : (
|
) : (
|
||||||
@@ -149,9 +149,9 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
onClick={() => setShowAdminSecret((show) => !show)}
|
onClick={() => setShowAdminSecret((show) => !show)}
|
||||||
>
|
>
|
||||||
{showAdminSecret ? (
|
{showAdminSecret ? (
|
||||||
<EyeOffIcon className="w-5 h-5" />
|
<EyeOffIcon className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<EyeIcon className="w-5 h-5" />
|
<EyeIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,13 +159,13 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
|
|
||||||
<Divider component="li" className="!my-4" />
|
<Divider component="li" className="!my-4" />
|
||||||
|
|
||||||
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
<ListItem.Root className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3">
|
||||||
<ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text>
|
<ListItem.Text>NHOST_WEBHOOK_SECRET</ListItem.Text>
|
||||||
|
|
||||||
<div className="grid grid-flow-col gap-2 lg:col-span-2 items-center justify-start">
|
<div className="grid grid-flow-col items-center justify-start gap-2 lg:col-span-2">
|
||||||
<Text className="text-greyscaleGreyDark truncate">
|
<Text className="truncate text-greyscaleGreyDark">
|
||||||
{showWebhookSecret ? (
|
{showWebhookSecret ? (
|
||||||
<InlineCode className="!text-sm font-medium max-h-[initial] h-[initial]">
|
<InlineCode className="!text-sm font-medium">
|
||||||
{data?.app?.webhookSecret}
|
{data?.app?.webhookSecret}
|
||||||
</InlineCode>
|
</InlineCode>
|
||||||
) : (
|
) : (
|
||||||
@@ -184,9 +184,9 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
onClick={() => setShowWebhookSecret((show) => !show)}
|
onClick={() => setShowWebhookSecret((show) => !show)}
|
||||||
>
|
>
|
||||||
{showWebhookSecret ? (
|
{showWebhookSecret ? (
|
||||||
<EyeOffIcon className="w-5 h-5" />
|
<EyeOffIcon className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<EyeIcon className="w-5 h-5" />
|
<EyeIcon className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,7 +196,7 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
|
|
||||||
{systemEnvironmentVariables.map((environmentVariable, index) => (
|
{systemEnvironmentVariables.map((environmentVariable, index) => (
|
||||||
<Fragment key={environmentVariable.key}>
|
<Fragment key={environmentVariable.key}>
|
||||||
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 gap-2">
|
<ListItem.Root className="grid grid-cols-2 gap-2 px-4 lg:grid-cols-3">
|
||||||
<ListItem.Text>{environmentVariable.key}</ListItem.Text>
|
<ListItem.Text>{environmentVariable.key}</ListItem.Text>
|
||||||
|
|
||||||
<Text className="truncate lg:col-span-2">
|
<Text className="truncate lg:col-span-2">
|
||||||
@@ -212,7 +212,7 @@ export default function SystemEnvironmentVariableSettings() {
|
|||||||
|
|
||||||
<Divider component="li" className="!mt-4 !mb-2.5" />
|
<Divider component="li" className="!mt-4 !mb-2.5" />
|
||||||
|
|
||||||
<ListItem.Root className="px-4 grid grid-cols-2 lg:grid-cols-3 justify-start">
|
<ListItem.Root className="grid grid-cols-2 justify-start px-4 lg:grid-cols-3">
|
||||||
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
|
<ListItem.Text>NHOST_JWT_SECRET</ListItem.Text>
|
||||||
|
|
||||||
<div className="grid grid-flow-row md:grid-flow-col gap-1.5 justify-center text-center lg:text-left lg:justify-start items-center lg:col-span-2">
|
<div className="grid grid-flow-row md:grid-flow-col gap-1.5 justify-center text-center lg:text-left lg:justify-start items-center lg:col-span-2">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import ControlledCheckbox from '@/components/common/ControlledCheckbox';
|
|||||||
import Form from '@/components/common/Form';
|
import Form from '@/components/common/Form';
|
||||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||||
import {
|
import {
|
||||||
GetAppLoginDataDocument,
|
|
||||||
useSignInMethodsQuery,
|
useSignInMethodsQuery,
|
||||||
useUpdateAppMutation,
|
useUpdateAppMutation,
|
||||||
} from '@/generated/graphql';
|
} from '@/generated/graphql';
|
||||||
@@ -25,9 +24,7 @@ export interface EmailAndPasswordFormValues {
|
|||||||
|
|
||||||
export default function EmailAndPasswordSettings() {
|
export default function EmailAndPasswordSettings() {
|
||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const [updateApp] = useUpdateAppMutation({
|
const [updateApp] = useUpdateAppMutation();
|
||||||
refetchQueries: [GetAppLoginDataDocument],
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data, error, loading } = useSignInMethodsQuery({
|
const { data, error, loading } = useSignInMethodsQuery({
|
||||||
variables: {
|
variables: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Form from '@/components/common/Form';
|
import Form from '@/components/common/Form';
|
||||||
import SettingsContainer from '@/components/settings/SettingsContainer';
|
import SettingsContainer from '@/components/settings/SettingsContainer';
|
||||||
import {
|
import {
|
||||||
useGetAppLoginDataQuery,
|
useSignInMethodsQuery,
|
||||||
useUpdateAppMutation,
|
useUpdateAppMutation,
|
||||||
} from '@/generated/graphql';
|
} from '@/generated/graphql';
|
||||||
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
import { useCurrentWorkspaceAndApplication } from '@/hooks/useCurrentWorkspaceAndApplication';
|
||||||
@@ -27,10 +27,11 @@ export default function TwitterProviderSettings() {
|
|||||||
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
const { currentApplication } = useCurrentWorkspaceAndApplication();
|
||||||
const [updateApp] = useUpdateAppMutation();
|
const [updateApp] = useUpdateAppMutation();
|
||||||
|
|
||||||
const { data, loading, error } = useGetAppLoginDataQuery({
|
const { data, loading, error } = useSignInMethodsQuery({
|
||||||
variables: {
|
variables: {
|
||||||
id: currentApplication?.id,
|
id: currentApplication?.id,
|
||||||
},
|
},
|
||||||
|
fetchPolicy: 'cache-only',
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<TwitterProviderFormValues>({
|
const form = useForm<TwitterProviderFormValues>({
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import clsx from 'clsx';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
export type DeploymentStatus =
|
export type DeploymentStatus =
|
||||||
| 'DEPLOYING'
|
| 'DEPLOYING'
|
||||||
| 'DEPLOYED'
|
| 'DEPLOYED'
|
||||||
| 'FAILED'
|
| 'FAILED'
|
||||||
|
| 'PENDING'
|
||||||
|
| 'SCHEDULED'
|
||||||
| undefined
|
| undefined
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
@@ -12,32 +14,30 @@ type StatusCircleProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function StatusCircle(props: StatusCircleProps) {
|
export function StatusCircle({ status, className }: StatusCircleProps) {
|
||||||
const { status, className } = props;
|
|
||||||
|
|
||||||
const baseClasses = 'w-1.5 h-1.5 rounded-full';
|
const baseClasses = 'w-1.5 h-1.5 rounded-full';
|
||||||
|
|
||||||
if (!status) {
|
if (status === 'DEPLOYING' || status === 'PENDING') {
|
||||||
const classes = clsx(baseClasses, 'bg-gray-300', className);
|
return (
|
||||||
return <div className={classes} />;
|
<div
|
||||||
}
|
className={twMerge(
|
||||||
|
baseClasses,
|
||||||
if (status === 'DEPLOYING') {
|
'bg-yellow-300 animate-pulse',
|
||||||
const classes = clsx(baseClasses, 'bg-yellow-300', className);
|
className,
|
||||||
return <div className={classes} />;
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'DEPLOYED') {
|
if (status === 'DEPLOYED') {
|
||||||
const classes = clsx(baseClasses, 'bg-green-300', className);
|
return <div className={twMerge(baseClasses, 'bg-green-300', className)} />;
|
||||||
return <div className={classes} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'FAILED') {
|
if (status === 'FAILED') {
|
||||||
const classes = clsx(baseClasses, 'bg-red', className);
|
return <div className={twMerge(baseClasses, 'bg-red', className)} />;
|
||||||
return <div className={classes} />;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return <div className={twMerge(baseClasses, 'bg-gray-300', className)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default StatusCircle;
|
export default StatusCircle;
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ import type { FormControlProps } from '@/ui/v2/FormControl';
|
|||||||
import ChevronDownIcon from '@/ui/v2/icons/ChevronDownIcon';
|
import ChevronDownIcon from '@/ui/v2/icons/ChevronDownIcon';
|
||||||
import XIcon from '@/ui/v2/icons/XIcon';
|
import XIcon from '@/ui/v2/icons/XIcon';
|
||||||
import type { InputProps } from '@/ui/v2/Input';
|
import type { InputProps } from '@/ui/v2/Input';
|
||||||
import Input from '@/ui/v2/Input';
|
import Input, { inputClasses } from '@/ui/v2/Input';
|
||||||
import { OptionBase } from '@/ui/v2/Option';
|
import { OptionBase } from '@/ui/v2/Option';
|
||||||
import { OptionGroupBase } from '@/ui/v2/OptionGroup';
|
import { OptionGroupBase } from '@/ui/v2/OptionGroup';
|
||||||
import type { StyledComponent } from '@emotion/styled';
|
import type { StyledComponent } from '@emotion/styled';
|
||||||
import type { UseAutocompleteProps } from '@mui/base/AutocompleteUnstyled';
|
import type { UseAutocompleteProps } from '@mui/base/AutocompleteUnstyled';
|
||||||
import { createFilterOptions } from '@mui/base/AutocompleteUnstyled';
|
import { createFilterOptions } from '@mui/base/AutocompleteUnstyled';
|
||||||
import PopperUnstyled from '@mui/base/PopperUnstyled';
|
import PopperUnstyled from '@mui/base/PopperUnstyled';
|
||||||
import { darken, inputBaseClasses, styled } from '@mui/material';
|
import { darken, styled } from '@mui/material';
|
||||||
import type { AutocompleteProps as MaterialAutocompleteProps } from '@mui/material/Autocomplete';
|
import type { AutocompleteProps as MaterialAutocompleteProps } from '@mui/material/Autocomplete';
|
||||||
import MaterialAutocomplete, {
|
import MaterialAutocomplete, {
|
||||||
autocompleteClasses as materialAutocompleteClasses,
|
autocompleteClasses as materialAutocompleteClasses,
|
||||||
} from '@mui/material/Autocomplete';
|
} from '@mui/material/Autocomplete';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import type { ForwardedRef } from 'react';
|
import type { ForwardedRef } from 'react';
|
||||||
import { forwardRef, useEffect, useState } from 'react';
|
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
export interface AutocompleteOption<TValue = string> {
|
export interface AutocompleteOption<TValue = string> {
|
||||||
/**
|
/**
|
||||||
@@ -112,6 +112,12 @@ const StyledTag = styled(Chip)(({ theme }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
|
const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
|
||||||
|
[`&:not(.${materialAutocompleteClasses.focused})`]: {
|
||||||
|
[`& .${inputClasses.root}`]: {
|
||||||
|
maxHeight: 40,
|
||||||
|
overflow: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
[`.${materialAutocompleteClasses.endAdornment}`]: {
|
[`.${materialAutocompleteClasses.endAdornment}`]: {
|
||||||
right: theme.spacing(1.5),
|
right: theme.spacing(1.5),
|
||||||
},
|
},
|
||||||
@@ -127,7 +133,7 @@ const StyledAutocomplete = styled(MaterialAutocomplete)(({ theme }) => ({
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export const AutocompletePopper = styled(PopperUnstyled)(({ theme }) => ({
|
export const AutocompletePopper = styled(PopperUnstyled)(({ theme }) => ({
|
||||||
zIndex: 1,
|
zIndex: theme.zIndex.modal + 1,
|
||||||
boxShadow: 'none',
|
boxShadow: 'none',
|
||||||
minWidth: 320,
|
minWidth: 320,
|
||||||
maxWidth: 600,
|
maxWidth: 600,
|
||||||
@@ -196,6 +202,7 @@ function Autocomplete(
|
|||||||
}: AutocompleteProps<AutocompleteOption>,
|
}: AutocompleteProps<AutocompleteOption>,
|
||||||
ref: ForwardedRef<HTMLInputElement>,
|
ref: ForwardedRef<HTMLInputElement>,
|
||||||
) {
|
) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>();
|
||||||
const { formControl: formControlSlotProps, ...defaultComponentsProps } =
|
const { formControl: formControlSlotProps, ...defaultComponentsProps } =
|
||||||
slotProps || {};
|
slotProps || {};
|
||||||
|
|
||||||
@@ -262,6 +269,17 @@ function Autocomplete(
|
|||||||
onInputChange(event, value, reason);
|
onInputChange(event, value, reason);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== 'Escape') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
if (document.activeElement instanceof HTMLElement) {
|
||||||
|
document.activeElement.blur();
|
||||||
|
}
|
||||||
|
}}
|
||||||
PopperComponent={AutocompletePopper}
|
PopperComponent={AutocompletePopper}
|
||||||
popupIcon={<ChevronDownIcon sx={{ width: 12, height: 12 }} />}
|
popupIcon={<ChevronDownIcon sx={{ width: 12, height: 12 }} />}
|
||||||
getOptionLabel={(option) => {
|
getOptionLabel={(option) => {
|
||||||
@@ -344,13 +362,14 @@ function Autocomplete(
|
|||||||
...params
|
...params
|
||||||
}) => (
|
}) => (
|
||||||
<Input
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
input: {
|
input: {
|
||||||
className: slotProps?.input?.className,
|
className: slotProps?.input?.className,
|
||||||
sx: props.multiple
|
sx: props.multiple
|
||||||
? {
|
? {
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
[`& .${inputBaseClasses.input}`]: {
|
[`& .${inputClasses.input}`]: {
|
||||||
minWidth: 30,
|
minWidth: 30,
|
||||||
width: 0,
|
width: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ function FormControlLabel(
|
|||||||
|
|
||||||
FormControlLabel.displayName = 'NhostFormControlLabel';
|
FormControlLabel.displayName = 'NhostFormControlLabel';
|
||||||
|
|
||||||
|
export { formControlLabelClasses } from '@mui/material/FormControlLabel';
|
||||||
export default forwardRef(FormControlLabel);
|
export default forwardRef(FormControlLabel);
|
||||||
|
|||||||
@@ -71,9 +71,12 @@ const StyledInputBase = styled(MaterialInputBase)(({ theme }) => ({
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
},
|
},
|
||||||
[`&.${inputBaseClasses.disabled}`]: {
|
[`&.${inputBaseClasses.disabled}`]: {
|
||||||
color: theme.palette.grey[600],
|
color: `${theme.palette.grey[600]} !important`,
|
||||||
borderColor: darken(theme.palette.grey[300], 0.1),
|
borderColor: `${darken(theme.palette.grey[300], 0.1)} !important`,
|
||||||
backgroundColor: lighten(theme.palette.action.disabled, 0.75),
|
backgroundColor: `${lighten(
|
||||||
|
theme.palette.action.disabled,
|
||||||
|
0.75,
|
||||||
|
)} !important`,
|
||||||
},
|
},
|
||||||
[`&:not(.${inputBaseClasses.disabled}):hover`]: {
|
[`&:not(.${inputBaseClasses.disabled}):hover`]: {
|
||||||
borderColor: theme.palette.grey[600],
|
borderColor: theme.palette.grey[600],
|
||||||
@@ -165,4 +168,6 @@ function Input(
|
|||||||
|
|
||||||
Input.displayName = 'NhostInput';
|
Input.displayName = 'NhostInput';
|
||||||
|
|
||||||
|
export { inputBaseClasses as inputClasses } from '@mui/material/InputBase';
|
||||||
|
|
||||||
export default forwardRef(Input);
|
export default forwardRef(Input);
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
import type { LinkProps as MaterialLinkProps } from '@mui/material/Link';
|
import type { LinkProps as MaterialLinkProps } from '@mui/material/Link';
|
||||||
import MaterialLink from '@mui/material/Link';
|
import MaterialLink from '@mui/material/Link';
|
||||||
|
import type { ForwardedRef } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
export interface LinkProps extends MaterialLinkProps {}
|
export interface LinkProps extends MaterialLinkProps {}
|
||||||
|
|
||||||
function Link({ children, ...props }: LinkProps) {
|
function Link(
|
||||||
return <MaterialLink {...props}>{children}</MaterialLink>;
|
{ children, ...props }: LinkProps,
|
||||||
|
ref: ForwardedRef<HTMLAnchorElement>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<MaterialLink ref={ref} {...props}>
|
||||||
|
{children}
|
||||||
|
</MaterialLink>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Link.displayName = 'NhostLink';
|
Link.displayName = 'NhostLink';
|
||||||
|
|
||||||
export default Link;
|
export default forwardRef(Link);
|
||||||
|
|||||||
17
dashboard/src/components/ui/v2/ListItem/ListItemAvatar.tsx
Normal file
17
dashboard/src/components/ui/v2/ListItem/ListItemAvatar.tsx
Normal 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;
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import ListItemAvatar from './ListItemAvatar';
|
||||||
import ListItemButton from './ListItemButton';
|
import ListItemButton from './ListItemButton';
|
||||||
import ListItemIcon from './ListItemIcon';
|
import ListItemIcon from './ListItemIcon';
|
||||||
import ListItemRoot from './ListItemRoot';
|
import ListItemRoot from './ListItemRoot';
|
||||||
import ListItemText from './ListItemText';
|
import ListItemText from './ListItemText';
|
||||||
|
|
||||||
|
export * from './ListItemAvatar';
|
||||||
|
export { default as ListItemAvatar } from './ListItemAvatar';
|
||||||
export * from './ListItemButton';
|
export * from './ListItemButton';
|
||||||
export { default as ListItemButton } from './ListItemButton';
|
export { default as ListItemButton } from './ListItemButton';
|
||||||
export * from './ListItemIcon';
|
export * from './ListItemIcon';
|
||||||
@@ -13,6 +16,7 @@ export * from './ListItemText';
|
|||||||
export { default as ListItemText } from './ListItemText';
|
export { default as ListItemText } from './ListItemText';
|
||||||
|
|
||||||
export const ListItem = {
|
export const ListItem = {
|
||||||
|
Avatar: ListItemAvatar,
|
||||||
Root: ListItemRoot,
|
Root: ListItemRoot,
|
||||||
Button: ListItemButton,
|
Button: ListItemButton,
|
||||||
Icon: ListItemIcon,
|
Icon: ListItemIcon,
|
||||||
|
|||||||
@@ -11,16 +11,21 @@ export interface OptionProps<TValue extends {}>
|
|||||||
extends OptionUnstyledProps<TValue> {}
|
extends OptionUnstyledProps<TValue> {}
|
||||||
|
|
||||||
const StyledOption = styled(OptionUnstyled)(({ theme }) => ({
|
const StyledOption = styled(OptionUnstyled)(({ theme }) => ({
|
||||||
|
[`&.${optionUnstyledClasses.disabled}:not(.${optionUnstyledClasses.highlighted}):hover`]:
|
||||||
|
{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
[`&.${optionUnstyledClasses.highlighted}`]: {
|
[`&.${optionUnstyledClasses.highlighted}`]: {
|
||||||
backgroundColor: darken(theme.palette.action.active, 0.025),
|
backgroundColor: darken(theme.palette.action.active, 0.025),
|
||||||
},
|
},
|
||||||
[`&.${optionUnstyledClasses.highlighted}:hover`]: {
|
[`&.${optionUnstyledClasses.highlighted}:not(.${optionUnstyledClasses.disabled}):hover`]:
|
||||||
backgroundColor: darken(theme.palette.action.hover, 0.1),
|
{
|
||||||
},
|
backgroundColor: darken(theme.palette.action.hover, 0.1),
|
||||||
|
},
|
||||||
[`&.${optionUnstyledClasses.disabled}`]: {
|
[`&.${optionUnstyledClasses.disabled}`]: {
|
||||||
color: theme.palette.text.disabled,
|
color: theme.palette.text.disabled,
|
||||||
},
|
},
|
||||||
[`&:hover:not(.${optionUnstyledClasses.disabled}):not(.${optionUnstyledClasses.highlighted})`]:
|
[`&:not(.${optionUnstyledClasses.disabled}):not(.${optionUnstyledClasses.highlighted}):hover`]:
|
||||||
{
|
{
|
||||||
backgroundColor: theme.palette.action.hover,
|
backgroundColor: theme.palette.action.hover,
|
||||||
},
|
},
|
||||||
|
|||||||
119
dashboard/src/components/ui/v2/Radio/Radio.tsx
Normal file
119
dashboard/src/components/ui/v2/Radio/Radio.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import type { FormControlLabelProps } from '@/ui/v2/FormControlLabel';
|
||||||
|
import FormControlLabel, {
|
||||||
|
formControlLabelClasses,
|
||||||
|
} from '@/ui/v2/FormControlLabel';
|
||||||
|
import { styled } from '@mui/material';
|
||||||
|
import type { RadioProps as MaterialRadioProps } from '@mui/material/Radio';
|
||||||
|
import MaterialRadio from '@mui/material/Radio';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
import type { ForwardedRef, PropsWithoutRef } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
export interface RadioProps extends MaterialRadioProps {
|
||||||
|
/**
|
||||||
|
* Value of the radio button.
|
||||||
|
*/
|
||||||
|
value?: string;
|
||||||
|
/**
|
||||||
|
* Label to be displayed next to the radio button.
|
||||||
|
*/
|
||||||
|
label?: string;
|
||||||
|
/**
|
||||||
|
* Props to be passed to individual component slots.
|
||||||
|
*/
|
||||||
|
slotProps?: {
|
||||||
|
/**
|
||||||
|
* Props to be passed to the radio button.
|
||||||
|
*/
|
||||||
|
radio?: Partial<MaterialRadioProps>;
|
||||||
|
/**
|
||||||
|
* Props to be passed to the form control label.
|
||||||
|
*/
|
||||||
|
formControl?: Partial<PropsWithoutRef<FormControlLabelProps>>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
|
||||||
|
[`& .${formControlLabelClasses.label}`]: {
|
||||||
|
display: 'inline-block',
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
fontSize: theme.typography.pxToRem(15),
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: theme.typography.pxToRem(22),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledRadio = styled(MaterialRadio)(({ theme }) => ({
|
||||||
|
padding: 0,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
color: theme.palette.action.disabled,
|
||||||
|
[`& > svg`]: {
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function Radio(
|
||||||
|
{ label, value, slotProps, ...props }: RadioProps,
|
||||||
|
ref: ForwardedRef<HTMLInputElement>,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<StyledFormControlLabel
|
||||||
|
{...(slotProps?.formControl || {})}
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
control={
|
||||||
|
<StyledRadio
|
||||||
|
checkedIcon={
|
||||||
|
<SvgIcon
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
>
|
||||||
|
<circle cx="9" cy="9" r="4" fill="currentColor" />
|
||||||
|
<rect
|
||||||
|
x=".75"
|
||||||
|
y=".75"
|
||||||
|
width="16.5"
|
||||||
|
height="16.5"
|
||||||
|
rx="8.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
}
|
||||||
|
icon={
|
||||||
|
<SvgIcon
|
||||||
|
width={18}
|
||||||
|
height={18}
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 18 18"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x=".75"
|
||||||
|
y=".75"
|
||||||
|
width="16.5"
|
||||||
|
height="16.5"
|
||||||
|
rx="8.25"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
}
|
||||||
|
disableRipple
|
||||||
|
ref={ref}
|
||||||
|
{...slotProps?.radio}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Radio.displayName = 'NhostRadio';
|
||||||
|
|
||||||
|
export default forwardRef(Radio);
|
||||||
2
dashboard/src/components/ui/v2/Radio/index.ts
Normal file
2
dashboard/src/components/ui/v2/Radio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './Radio';
|
||||||
|
export { default } from './Radio';
|
||||||
14
dashboard/src/components/ui/v2/RadioGroup/RadioGroup.tsx
Normal file
14
dashboard/src/components/ui/v2/RadioGroup/RadioGroup.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { RadioGroupProps as MaterialRadioGroupProps } from '@mui/material/RadioGroup';
|
||||||
|
import MaterialRadioGroup from '@mui/material/RadioGroup';
|
||||||
|
import type { ForwardedRef } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
export interface RadioGroupProps extends MaterialRadioGroupProps {}
|
||||||
|
|
||||||
|
function RadioGroup(props: RadioGroupProps, ref: ForwardedRef<HTMLDivElement>) {
|
||||||
|
return <MaterialRadioGroup ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
RadioGroup.displayName = 'NhostRadioGroup';
|
||||||
|
|
||||||
|
export default forwardRef(RadioGroup);
|
||||||
2
dashboard/src/components/ui/v2/RadioGroup/index.ts
Normal file
2
dashboard/src/components/ui/v2/RadioGroup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './RadioGroup';
|
||||||
|
export { default } from './RadioGroup';
|
||||||
@@ -46,9 +46,12 @@ const StyledButton = styled(ButtonUnstyled)(({ theme }) => ({
|
|||||||
borderColor: theme.palette.grey[600],
|
borderColor: theme.palette.grey[600],
|
||||||
},
|
},
|
||||||
[`&.${selectUnstyledClasses.disabled}`]: {
|
[`&.${selectUnstyledClasses.disabled}`]: {
|
||||||
color: theme.palette.grey[600],
|
color: `${theme.palette.grey[600]} !important`,
|
||||||
borderColor: darken(theme.palette.grey[300], 0.1),
|
borderColor: `${darken(theme.palette.grey[300], 0.1)} !important`,
|
||||||
backgroundColor: lighten(theme.palette.grey[200], 0.5),
|
backgroundColor: `${lighten(
|
||||||
|
theme.palette.action.disabled,
|
||||||
|
0.75,
|
||||||
|
)} !important`,
|
||||||
},
|
},
|
||||||
[`&.${selectUnstyledClasses.focusVisible}, &.${selectUnstyledClasses.expanded}`]:
|
[`&.${selectUnstyledClasses.focusVisible}, &.${selectUnstyledClasses.expanded}`]:
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export default {
|
|||||||
const Template: ComponentStory<typeof Switch> = function Template(
|
const Template: ComponentStory<typeof Switch> = function Template(
|
||||||
args: SwitchProps,
|
args: SwitchProps,
|
||||||
) {
|
) {
|
||||||
return <Switch {...args} />;
|
return <Switch label="Accept Rules" {...args} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Default = Template.bind({});
|
export const Default = Template.bind({});
|
||||||
|
|||||||
@@ -1,13 +1,39 @@
|
|||||||
|
import type { FormControlLabelProps } from '@/ui/v2/FormControlLabel';
|
||||||
|
import FormControlLabel from '@/ui/v2/FormControlLabel';
|
||||||
import SwitchUnstyled, {
|
import SwitchUnstyled, {
|
||||||
switchUnstyledClasses,
|
switchUnstyledClasses,
|
||||||
} from '@mui/base/SwitchUnstyled';
|
} from '@mui/base/SwitchUnstyled';
|
||||||
import type { SwitchUnstyledProps } from '@mui/base/SwitchUnstyled/SwitchUnstyled.types';
|
import type { SwitchUnstyledProps } from '@mui/base/SwitchUnstyled/SwitchUnstyled.types';
|
||||||
|
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import type { ForwardedRef } from 'react';
|
import type { ForwardedRef, PropsWithoutRef } from 'react';
|
||||||
import { forwardRef } from 'react';
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
export interface SwitchProps extends SwitchUnstyledProps {}
|
export interface SwitchProps extends SwitchUnstyledProps {
|
||||||
|
/**
|
||||||
|
* Label to be displayed next to the checkbox.
|
||||||
|
*/
|
||||||
|
label?: FormControlLabelProps['label'];
|
||||||
|
/**
|
||||||
|
* Props to be passed to the internal components.
|
||||||
|
*/
|
||||||
|
slotProps?: SwitchUnstyledProps['slotProps'] & {
|
||||||
|
/**
|
||||||
|
* Props to be passed to the `Switch` component.
|
||||||
|
*/
|
||||||
|
root?: Partial<SwitchUnstyledProps>;
|
||||||
|
/**
|
||||||
|
* Props to be passed to the `FormControlLabel` component.
|
||||||
|
*/
|
||||||
|
formControlLabel?: Partial<PropsWithoutRef<FormControlLabelProps>>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
|
||||||
|
display: 'grid',
|
||||||
|
gridAutoFlow: 'column',
|
||||||
|
gap: theme.spacing(1.25),
|
||||||
|
justifyContent: 'start',
|
||||||
|
}));
|
||||||
|
|
||||||
const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({
|
const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -86,13 +112,21 @@ const StyledSwitch = styled(SwitchUnstyled)(({ theme }) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
function Switch(
|
function Switch(
|
||||||
{ children, ...props }: SwitchProps,
|
{ label, slotProps, ...props }: SwitchProps,
|
||||||
ref: ForwardedRef<HTMLSpanElement>,
|
ref: ForwardedRef<HTMLSpanElement>,
|
||||||
) {
|
) {
|
||||||
|
if (!label) {
|
||||||
|
return <StyledSwitch {...(slotProps?.root || {})} {...props} ref={ref} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSwitch {...props} ref={ref}>
|
<StyledFormControlLabel
|
||||||
{children}
|
{...(slotProps?.formControlLabel || {})}
|
||||||
</StyledSwitch>
|
control={
|
||||||
|
<StyledSwitch {...(slotProps?.root || {})} {...props} ref={ref} />
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
|
import { styled } from '@mui/material';
|
||||||
import type { TableCellProps as MaterialTableCellProps } from '@mui/material/TableCell';
|
import type { TableCellProps as MaterialTableCellProps } from '@mui/material/TableCell';
|
||||||
import MaterialTableCell from '@mui/material/TableCell';
|
import MaterialTableCell, { tableCellClasses } from '@mui/material/TableCell';
|
||||||
|
|
||||||
export interface TableCellProps extends MaterialTableCellProps {}
|
export interface TableCellProps extends MaterialTableCellProps {}
|
||||||
|
|
||||||
|
const StyledTableCell = styled(MaterialTableCell)(({ theme }) => ({
|
||||||
|
borderColor: theme.palette.grey[400],
|
||||||
|
[`&.${tableCellClasses.head}`]: {
|
||||||
|
fontSize: theme.typography.pxToRem(12),
|
||||||
|
lineHeight: theme.typography.pxToRem(16),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
function TableCell({ children, ...props }: TableCellProps) {
|
function TableCell({ children, ...props }: TableCellProps) {
|
||||||
return <MaterialTableCell {...props}>{children}</MaterialTableCell>;
|
return <StyledTableCell {...props}>{children}</StyledTableCell>;
|
||||||
}
|
}
|
||||||
|
|
||||||
TableCell.displayName = 'NhostTableCell';
|
TableCell.displayName = 'NhostTableCell';
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './ArrowCounterclockwiseIcon';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function FullPermissionIcon({ sx, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-label="Three filled horizontal lines"
|
||||||
|
{...props}
|
||||||
|
sx={{ width: 20, height: 20, ...sx }}
|
||||||
|
>
|
||||||
|
<path fill="currentColor" d="M5 15h10v2.5H5z" />
|
||||||
|
<path fill="currentColor" d="M5 8.75h10v2.5H5z" />
|
||||||
|
<path fill="currentColor" d="M5 2.5h10V5H5z" />
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FullPermissionIcon.displayName = 'NhostFullPermissionIcon';
|
||||||
|
|
||||||
|
export default FullPermissionIcon;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './FullPermissionIcon';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function NoPermissionIcon({ sx, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-label="Three horizontal lines"
|
||||||
|
{...props}
|
||||||
|
sx={{ width: 20, height: 20, ...sx }}
|
||||||
|
>
|
||||||
|
<path fill="currentColor" d="M5 15h10v2.5H5z" fillOpacity={0.3} />
|
||||||
|
<path fill="currentColor" d="M5 8.75h10v2.5H5z" fillOpacity={0.3} />
|
||||||
|
<path fill="currentColor" d="M5 2.5h10V5H5z" fillOpacity={0.3} />
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
NoPermissionIcon.displayName = 'NhostNoPermissionIcon';
|
||||||
|
|
||||||
|
export default NoPermissionIcon;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './NoPermissionIcon';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function PartialPermissionIcon({ sx, ...props }: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
aria-label="Three horizontal lines, the one in the middle filles"
|
||||||
|
{...props}
|
||||||
|
sx={{ width: 20, height: 20, ...sx }}
|
||||||
|
>
|
||||||
|
<path fill="currentColor" d="M5 15h10v2.5H5z" fillOpacity={0.3} />
|
||||||
|
<path fill="currentColor" d="M5 8.75h10v2.5H5z" />
|
||||||
|
<path fill="currentColor" d="M5 2.5h10V5H5z" fillOpacity={0.3} />
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PartialPermissionIcon.displayName = 'NhostPartialPermissionIcon';
|
||||||
|
|
||||||
|
export default PartialPermissionIcon;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './PartialPermissionIcon';
|
||||||
34
dashboard/src/components/ui/v2/icons/UsersIcon/UsersIcon.tsx
Normal file
34
dashboard/src/components/ui/v2/icons/UsersIcon/UsersIcon.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import type { IconProps } from '@/ui/v2/icons';
|
||||||
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
|
|
||||||
|
function UsersIcon(props: IconProps) {
|
||||||
|
return (
|
||||||
|
<SvgIcon
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
aria-label="Users"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M5.5 10a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeMiterlimit="10"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M9.713 3.621A3.25 3.25 0 1 1 10.595 10M1 12.337a5.501 5.501 0 0 1 9 0M10.595 10a5.493 5.493 0 0 1 4.5 2.337"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="none"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</SvgIcon>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UsersIcon.displayName = 'NhostUsersIcon';
|
||||||
|
|
||||||
|
export default UsersIcon;
|
||||||
1
dashboard/src/components/ui/v2/icons/UsersIcon/index.ts
Normal file
1
dashboard/src/components/ui/v2/icons/UsersIcon/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from './UsersIcon';
|
||||||
@@ -66,12 +66,14 @@ export default function CreateUserForm({
|
|||||||
setError,
|
setError,
|
||||||
} = form;
|
} = form;
|
||||||
|
|
||||||
const signUpUrl = generateAppServiceUrl(
|
const baseAuthUrl = generateAppServiceUrl(
|
||||||
currentApplication?.subdomain,
|
currentApplication?.subdomain,
|
||||||
currentApplication?.region.awsName,
|
currentApplication?.region?.awsName,
|
||||||
'auth',
|
'auth',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const signUpUrl = `${baseAuthUrl}/signup/email-password`;
|
||||||
|
|
||||||
async function handleCreateUser({ email, password }: CreateUserFormValues) {
|
async function handleCreateUser({ email, password }: CreateUserFormValues) {
|
||||||
setCreateUserFormError(null);
|
setCreateUserFormError(null);
|
||||||
|
|
||||||
|
|||||||
@@ -8,18 +8,18 @@ import Button from '@/ui/v2/Button';
|
|||||||
import Chip from '@/ui/v2/Chip';
|
import Chip from '@/ui/v2/Chip';
|
||||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
import IconButton from '@/ui/v2/IconButton';
|
import IconButton from '@/ui/v2/IconButton';
|
||||||
|
import CopyIcon from '@/ui/v2/icons/CopyIcon';
|
||||||
import Input from '@/ui/v2/Input';
|
import Input from '@/ui/v2/Input';
|
||||||
import InputLabel from '@/ui/v2/InputLabel';
|
import InputLabel from '@/ui/v2/InputLabel';
|
||||||
import Option from '@/ui/v2/Option';
|
import Option from '@/ui/v2/Option';
|
||||||
import Text from '@/ui/v2/Text';
|
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 {
|
import {
|
||||||
useGetRolesQuery,
|
useGetRolesQuery,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} 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 { yupResolver } from '@hookform/resolvers/yup';
|
||||||
import { Avatar } from '@mui/material';
|
import { Avatar } from '@mui/material';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -137,7 +137,7 @@ export default function EditUserForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { data: dataRoles } = useGetRolesQuery({
|
const { data: dataRoles } = useGetRolesQuery({
|
||||||
variables: { id: currentApplication.id },
|
variables: { id: currentApplication?.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const allAvailableProjectRoles = getUserRoles(
|
const allAvailableProjectRoles = getUserRoles(
|
||||||
@@ -206,11 +206,7 @@ export default function EditUserForm({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Dropdown.Root>
|
<Dropdown.Root>
|
||||||
<Dropdown.Trigger
|
<Dropdown.Trigger autoFocus={false} asChild className="gap-2">
|
||||||
autoFocus={false}
|
|
||||||
asChild
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
<Button variant="outlined" color="secondary">
|
<Button variant="outlined" color="secondary">
|
||||||
Actions
|
Actions
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import Chip from '@/ui/v2/Chip';
|
|||||||
import Divider from '@/ui/v2/Divider';
|
import Divider from '@/ui/v2/Divider';
|
||||||
import { Dropdown } from '@/ui/v2/Dropdown';
|
import { Dropdown } from '@/ui/v2/Dropdown';
|
||||||
import IconButton from '@/ui/v2/IconButton';
|
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 DotsHorizontalIcon from '@/ui/v2/icons/DotsHorizontalIcon';
|
||||||
import TrashIcon from '@/ui/v2/icons/TrashIcon';
|
import TrashIcon from '@/ui/v2/icons/TrashIcon';
|
||||||
import UserIcon from '@/ui/v2/icons/UserIcon';
|
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 type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||||
import {
|
import {
|
||||||
useDeleteRemoteAppUserRolesMutation,
|
useDeleteRemoteAppUserRolesMutation,
|
||||||
@@ -21,9 +23,6 @@ import {
|
|||||||
useRemoteAppDeleteUserMutation,
|
useRemoteAppDeleteUserMutation,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
|
|
||||||
import getUserRoles from '@/utils/settings/getUserRoles';
|
|
||||||
import { toastStyleProps } from '@/utils/settings/settingsConstants';
|
|
||||||
import type { ApolloQueryResult } from '@apollo/client';
|
import type { ApolloQueryResult } from '@apollo/client';
|
||||||
import { Avatar } from '@mui/material';
|
import { Avatar } from '@mui/material';
|
||||||
import { formatDistance } from 'date-fns';
|
import { formatDistance } from 'date-fns';
|
||||||
@@ -77,7 +76,7 @@ export default function UsersBody({
|
|||||||
* in the drawer form.
|
* in the drawer form.
|
||||||
*/
|
*/
|
||||||
const { data: dataRoles } = useGetRolesQuery({
|
const { data: dataRoles } = useGetRolesQuery({
|
||||||
variables: { id: currentApplication.id },
|
variables: { id: currentApplication?.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const allAvailableProjectRoles = useMemo(
|
const allAvailableProjectRoles = useMemo(
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Avatar } from '@/ui/Avatar';
|
import { Avatar } from '@/ui/Avatar';
|
||||||
import Text from '@/ui/v2/Text';
|
import Text from '@/ui/v2/Text';
|
||||||
import { nhost } from '@/utils/nhost';
|
|
||||||
import { useGetWorkspacesQuery } from '@/utils/__generated__/graphql';
|
import { useGetWorkspacesQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { nhost } from '@/utils/nhost';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
export default function SidebarWorkspaces() {
|
export default function SidebarWorkspaces() {
|
||||||
const user = nhost.auth.getUser();
|
const user = nhost.auth.getUser();
|
||||||
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery();
|
const { data, loading, startPolling, stopPolling } = useGetWorkspacesQuery({
|
||||||
|
fetchPolicy: 'cache-and-network',
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
startPolling(1000);
|
startPolling(1000);
|
||||||
@@ -28,7 +30,7 @@ export default function SidebarWorkspaces() {
|
|||||||
<div className="mt-3 mb-4 space-y-2">
|
<div className="mt-3 mb-4 space-y-2">
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
<svg
|
<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"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
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"
|
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>
|
</svg>
|
||||||
<Text size="tiny" className="ml-2 self-center" color="greyscaleGrey">
|
<Text size="tiny" className="self-center ml-2" color="greyscaleGrey">
|
||||||
Creating first workspace...
|
Creating first workspace...
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,12 +68,12 @@ export default function SidebarWorkspaces() {
|
|||||||
>
|
>
|
||||||
{name === 'Default Workspace' && creatorUserId === user.id ? (
|
{name === 'Default Workspace' && creatorUserId === user.id ? (
|
||||||
<Avatar
|
<Avatar
|
||||||
className="h-8 w-8 self-center rounded-full"
|
className="self-center w-8 h-8 rounded-full"
|
||||||
name={user?.displayName}
|
name={user?.displayName}
|
||||||
avatarUrl={user?.avatarUrl}
|
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
|
<Image
|
||||||
src="/logos/new.svg"
|
src="/logos/new.svg"
|
||||||
alt="Nhost Logo"
|
alt="Nhost Logo"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import ChangeWorkspaceName from '@/components/applications/ChangeWorkspaceName';
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
|
import RemoveWorkspaceModal from '@/components/workspace/RemoveWorkspaceModal';
|
||||||
import { useUI } from '@/context/UIContext';
|
import { useUI } from '@/context/UIContext';
|
||||||
import { useGetWorkspace } from '@/hooks/use-GetWorkspace';
|
import { useGetWorkspace } from '@/hooks/use-GetWorkspace';
|
||||||
@@ -13,7 +13,6 @@ import { copy } from '@/utils/copy';
|
|||||||
import { nhost } from '@/utils/nhost';
|
import { nhost } from '@/utils/nhost';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
export default function WorkspaceHeader() {
|
export default function WorkspaceHeader() {
|
||||||
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
|
const { currentWorkspace } = useCurrentWorkspaceAndApplication();
|
||||||
@@ -21,14 +20,14 @@ export default function WorkspaceHeader() {
|
|||||||
query: { workspaceSlug },
|
query: { workspaceSlug },
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
|
|
||||||
const [changeWorkspaceNameModal, setChangeWorkspaceNameModal] =
|
|
||||||
useState(false);
|
|
||||||
const {
|
const {
|
||||||
openDeleteWorkspaceModal,
|
openDeleteWorkspaceModal,
|
||||||
closeDeleteWorkspaceModal,
|
closeDeleteWorkspaceModal,
|
||||||
deleteWorkspaceModal,
|
deleteWorkspaceModal,
|
||||||
} = useUI();
|
} = useUI();
|
||||||
|
|
||||||
|
const { openDialog } = useDialog();
|
||||||
|
|
||||||
const { data } = useGetWorkspace(workspaceSlug);
|
const { data } = useGetWorkspace(workspaceSlug);
|
||||||
|
|
||||||
const workspace = data?.workspaces[0];
|
const workspace = data?.workspaces[0];
|
||||||
@@ -45,11 +44,6 @@ export default function WorkspaceHeader() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col">
|
<div className="mx-auto flex max-w-3xl flex-col">
|
||||||
<Modal
|
|
||||||
showModal={changeWorkspaceNameModal}
|
|
||||||
close={() => setChangeWorkspaceNameModal(!changeWorkspaceNameModal)}
|
|
||||||
Component={ChangeWorkspaceName}
|
|
||||||
/>
|
|
||||||
<Modal
|
<Modal
|
||||||
showModal={deleteWorkspaceModal}
|
showModal={deleteWorkspaceModal}
|
||||||
close={closeDeleteWorkspaceModal}
|
close={closeDeleteWorkspaceModal}
|
||||||
@@ -112,9 +106,23 @@ export default function WorkspaceHeader() {
|
|||||||
>
|
>
|
||||||
<Dropdown.Item
|
<Dropdown.Item
|
||||||
className="py-2"
|
className="py-2"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
setChangeWorkspaceNameModal(!changeWorkspaceNameModal)
|
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
|
Change workspace name
|
||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user