Compare commits
113 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ad1cfcb13 | ||
|
|
25c0ffa83b | ||
|
|
cc98f33440 | ||
|
|
8812d9dcaf | ||
|
|
bf17981596 | ||
|
|
2f4b3768c7 | ||
|
|
73a7ba82ae | ||
|
|
ba3c49e443 | ||
|
|
88836f3b1f | ||
|
|
81716d9d9c | ||
|
|
a30da08e9b | ||
|
|
397bfc948c | ||
|
|
0d183761ae | ||
|
|
1902a114ec | ||
|
|
92e71a61f9 | ||
|
|
9790bcfe3e | ||
|
|
811b48eccf | ||
|
|
57987ed3a9 | ||
|
|
7f0db210ba | ||
|
|
d8c5117046 | ||
|
|
7633d04121 | ||
|
|
e8a378906a | ||
|
|
34ede5cf2c | ||
|
|
2deeb39a28 | ||
|
|
d98e73e57e | ||
|
|
4c6400fc52 | ||
|
|
c4f383f695 | ||
|
|
1708578f8f | ||
|
|
96228dfe69 | ||
|
|
2f5bc04e0c | ||
|
|
06b47e0fb9 | ||
|
|
412692c2f6 | ||
|
|
89f6fe6346 | ||
|
|
2e34d7b9d0 | ||
|
|
66e0cc8261 | ||
|
|
7eb9539807 | ||
|
|
906620a755 | ||
|
|
5e9ddb41d2 | ||
|
|
00132bd961 | ||
|
|
57b26152e4 | ||
|
|
5565451f18 | ||
|
|
4b18e02ad2 | ||
|
|
181c0ab19d | ||
|
|
939a158917 | ||
|
|
9c0a118721 | ||
|
|
129ec1edfc | ||
|
|
40439b9987 | ||
|
|
cffa161da7 | ||
|
|
4ffff86752 | ||
|
|
f9e170e958 | ||
|
|
b8cb491ab1 | ||
|
|
59249e5161 | ||
|
|
df6b85e98c | ||
|
|
85316e822f | ||
|
|
f7d7080dad | ||
|
|
ec24567d83 | ||
|
|
56c87dad64 | ||
|
|
47ab341ce4 | ||
|
|
5fed49e05b | ||
|
|
aee9a80ac8 | ||
|
|
5ef3f76ea0 | ||
|
|
4ca9641304 | ||
|
|
fd3b5c77e4 | ||
|
|
9ed8ce8a5e | ||
|
|
e7762cb2b5 | ||
|
|
e353d99de8 | ||
|
|
c4d289a4d5 | ||
|
|
e2065e22df | ||
|
|
d738884d7d | ||
|
|
b50404566f | ||
|
|
8caf3daa54 | ||
|
|
8a07613cbe | ||
|
|
736862c9cc | ||
|
|
ea99fb31d7 | ||
|
|
70433187cc | ||
|
|
39b10a2e9f | ||
|
|
4b8478004e | ||
|
|
61eb6cdc2d | ||
|
|
14187d381f | ||
|
|
99b78f147e | ||
|
|
2aa81a6cb9 | ||
|
|
a1edaf18ea | ||
|
|
4d835c4b9c | ||
|
|
44a3e6bd41 | ||
|
|
6ee2d1f5bf | ||
|
|
df51c3e64e | ||
|
|
9acae7d1c4 | ||
|
|
f6947a2194 | ||
|
|
31e636a9c8 | ||
|
|
0fdff345ac | ||
|
|
97db63791b | ||
|
|
a0931e282f | ||
|
|
e87505c564 | ||
|
|
c0635ae1c7 | ||
|
|
d2a9a9ae1d | ||
|
|
c97b43f149 | ||
|
|
2026bb7a9c | ||
|
|
1bc1e30f5e | ||
|
|
85526782f2 | ||
|
|
fad7f640de | ||
|
|
5ff4dd6e40 | ||
|
|
0bf28085b7 | ||
|
|
b302dbd27d | ||
|
|
72a365c5fc | ||
|
|
d11363a74c | ||
|
|
1bc2fabe59 | ||
|
|
f8243f9434 | ||
|
|
d9eb90604d | ||
|
|
cef647194d | ||
|
|
efd68c3f92 | ||
|
|
233232b06f | ||
|
|
5e962300f6 | ||
|
|
048b3389e6 |
19
.github/labeler.yml
vendored
19
.github/labeler.yml
vendored
@@ -1,24 +1,25 @@
|
||||
dashboard:
|
||||
- dashboard/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['dashboard/**/*']
|
||||
|
||||
documentation:
|
||||
- any:
|
||||
- docs/**/*
|
||||
- any: ['docs/**/*']
|
||||
|
||||
examples:
|
||||
- examples/**/*
|
||||
- any: ['examples/**/*']
|
||||
|
||||
sdk:
|
||||
- packages/**/*
|
||||
- any: ['packages/**/*']
|
||||
|
||||
integrations:
|
||||
- integrations/**/*
|
||||
- any: ['integrations/**/*']
|
||||
|
||||
react:
|
||||
- '{packages,examples,integrations}/*react*/**/*'
|
||||
- any: ['{packages,examples,integrations}/*react*/**/*']
|
||||
|
||||
nextjs:
|
||||
- '{packages,examples}/*next*/**/*'
|
||||
- any: ['{packages,examples}/*next*/**/*']
|
||||
|
||||
vue:
|
||||
- '{packages,examples,integrations}/*vue*/**/*'
|
||||
- any: ['{packages,examples,integrations}/*vue*/**/*']
|
||||
|
||||
49
.github/workflows/ci.yaml
vendored
49
.github/workflows/ci.yaml
vendored
@@ -7,12 +7,14 @@ on:
|
||||
- 'assets/**'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths-ignore:
|
||||
- 'assets/**'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'docs/**'
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: nhost
|
||||
@@ -27,12 +29,13 @@ env:
|
||||
NHOST_PRO_TEST_PROJECT_NAME: ${{ vars.NHOST_PRO_TEST_PROJECT_NAME }}
|
||||
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
|
||||
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: '${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}'
|
||||
NHOST_TEST_FREE_USER_EMAILS: ${{ secrets.NHOST_TEST_FREE_USER_EMAILS }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build @nhost packages
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
@@ -72,7 +75,7 @@ jobs:
|
||||
unit:
|
||||
name: Unit tests
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
|
||||
@@ -97,7 +100,7 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
|
||||
@@ -124,7 +127,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.build.outputs.matrix) }}
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: blacksmith-2vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
# * Install Node and dependencies. Package dependencies won't be downloaded again as they have been cached by the `build` job.
|
||||
@@ -169,6 +172,10 @@ jobs:
|
||||
- name: Set Dashboard Preview URL
|
||||
if: steps.fetch-dashboard-preview-url.outputs.preview_url != ''
|
||||
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
|
||||
- name: Run Onboarding Dashboard e2e tests
|
||||
if: matrix.package.path == 'dashboard'
|
||||
timeout-minutes: 10
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e:onboarding
|
||||
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 20
|
||||
@@ -177,23 +184,31 @@ jobs:
|
||||
- name: Run Local Dashboard e2e tests
|
||||
if: matrix.package.path == 'dashboard'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
pnpm --filter="${{ matrix.package.name }}" run e2e-local
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e:local
|
||||
|
||||
- name: Stop Nhost CLI
|
||||
if: matrix.package.path == 'dashboard'
|
||||
working-directory: ./nhost-test-project
|
||||
run: nhost down
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
name: Transform package name into a valid file name
|
||||
- name: Stop Nhost CLI for packages
|
||||
if: always() && (matrix.package.path == 'packages/hasura-auth-js' || matrix.package.path == 'packages/hasura-storage-js')
|
||||
working-directory: ./${{ matrix.package.path }}
|
||||
run: nhost down
|
||||
|
||||
- name: Encrypt Playwright report
|
||||
if: ${{ failure() && hashFiles('dashboard/playwright-report/**') != ''}}
|
||||
run: |
|
||||
PACKAGE_FILE_NAME=$(echo "${{ matrix.package.name }}" | sed 's/@//g; s/\//-/g')
|
||||
echo "fileName=$PACKAGE_FILE_NAME" >> $GITHUB_OUTPUT
|
||||
# * Run this step only if the previous step failed, and Playwright generated a report
|
||||
- name: Upload Playwright Report
|
||||
if: ${{ failure() && hashFiles(format('{0}/playwright-report/**', matrix.package.path)) != ''}}
|
||||
tar -czf dashboard/playwright-report.tar.gz dashboard/playwright-report/
|
||||
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 \
|
||||
-in dashboard/playwright-report.tar.gz \
|
||||
-out playwright-report.tar.gz.enc \
|
||||
-k "${{ secrets.PLAYWRIGHT_REPORT_ENCRYPTION_KEY }}"
|
||||
rm dashboard/playwright-report.tar.gz
|
||||
|
||||
- name: Upload encrypted Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() && hashFiles('dashboard/playwright-report/**') != ''}}
|
||||
with:
|
||||
name: playwright-${{ steps.file-name.outputs.fileName }}
|
||||
path: ${{format('{0}/playwright-report/**', matrix.package.path)}}
|
||||
name: encrypted-playwright-report-${{ github.run_id }}
|
||||
path: playwright-report.tar.gz.enc
|
||||
retention-days: 1
|
||||
|
||||
28
.github/workflows/deploy-dashboard.yaml
vendored
28
.github/workflows/deploy-dashboard.yaml
vendored
@@ -56,3 +56,31 @@ jobs:
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
- name: Send Discord notification (success)
|
||||
if: success()
|
||||
uses: tsickert/discord-webhook@v7.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_PRODUCTION }}
|
||||
embed-title: "Dashboard Deployment"
|
||||
embed-description: |
|
||||
**Status**: success
|
||||
**Triggered by**: ${{ github.actor }}
|
||||
|
||||
**Inputs**:
|
||||
- Git Ref: ${{ inputs.git_ref }}
|
||||
embed-color: '5763719'
|
||||
|
||||
- name: Send Discord notification (failure)
|
||||
if: failure()
|
||||
uses: tsickert/discord-webhook@v7.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_PRODUCTION }}
|
||||
embed-title: "Dashboard Deployment"
|
||||
embed-description: |
|
||||
**Status**: failure
|
||||
**Triggered by**: ${{ github.actor }}
|
||||
|
||||
**Inputs**:
|
||||
- Git Ref: ${{ inputs.git_ref }}
|
||||
embed-color: '15548997'
|
||||
|
||||
7
.github/workflows/labeler.yaml
vendored
7
.github/workflows/labeler.yaml
vendored
@@ -3,13 +3,12 @@ on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: '${{ secrets.GH_PAT }}'
|
||||
sync-labels: ''
|
||||
repo-token: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
|
||||
## Requirements
|
||||
|
||||
### Node.js v18
|
||||
|
||||
_⚠️ Node.js v16 is also supported for the time being but support will be dropped in the near future_.
|
||||
### Node.js v20 or later
|
||||
|
||||
### [pnpm](https://pnpm.io/) package manager
|
||||
|
||||
|
||||
@@ -61,9 +61,9 @@ Visit [https://docs.nhost.io](http://docs.nhost.io) for the complete documentati
|
||||
|
||||
Since Nhost is 100% open source, you can self-host the whole Nhost stack. Check out the example [docker-compose file](https://github.com/nhost/nhost/tree/main/examples/docker-compose) to self-host Nhost.
|
||||
|
||||
## Sign In and Make a Graphql Request
|
||||
## Sign In and Make a GraphQL Request
|
||||
|
||||
Install the `@nhost/nhost-js` package and start build your app:
|
||||
Install the `@nhost/nhost-js` package and start building your app:
|
||||
|
||||
```jsx
|
||||
import { NhostClient } from '@nhost/nhost-js'
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
// $schema provides code completion hints to IDEs.
|
||||
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
|
||||
"moderate": true,
|
||||
"allowlist": ["vue-template-compiler"]
|
||||
"allowlist": ["vue-template-compiler", { "id": "CVE-2025-48068", "path": "next" }]
|
||||
}
|
||||
|
||||
0
config/.husky/pre-commit
Normal file → Executable file
0
config/.husky/pre-commit
Normal file → Executable file
@@ -11,20 +11,9 @@
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "node",
|
||||
"target": "ES6",
|
||||
"target": "ESNext",
|
||||
"module": "CommonJS",
|
||||
"lib": [
|
||||
"es5",
|
||||
"dom",
|
||||
"es2015.promise",
|
||||
"es2015.symbol",
|
||||
"es2015.iterable",
|
||||
"es2015.collection",
|
||||
"es2015.symbol.wellknown",
|
||||
"es2015.core",
|
||||
"es2017.object",
|
||||
"es2017.string"
|
||||
],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
@@ -79,4 +68,4 @@
|
||||
"**/*/__tests__",
|
||||
"**/*/__mocks__"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,4 +25,6 @@ NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
|
||||
NEXT_PUBLIC_SOC2_REPORT_FILE_ID=
|
||||
@@ -76,6 +76,13 @@ module.exports = {
|
||||
],
|
||||
},
|
||||
],
|
||||
'jsx-a11y/label-has-associated-control': [
|
||||
2,
|
||||
{
|
||||
controlComponents: ['Input'],
|
||||
depth: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { NhostProvider } from '@/providers/nhost';
|
||||
import '@fontsource/inter';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||
import { NhostClient, NhostProvider } from '@nhost/nextjs';
|
||||
import { createClient } from '@nhost/nhost-js-beta';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { Buffer } from 'buffer';
|
||||
@@ -58,7 +59,9 @@ export const decorators = [
|
||||
</NhostApolloProvider>
|
||||
),
|
||||
(Story) => (
|
||||
<NhostProvider nhost={new NhostClient({ subdomain: 'local' })}>
|
||||
<NhostProvider
|
||||
nhost={createClient({ subdomain: 'local', region: 'local' })}
|
||||
>
|
||||
<Story />
|
||||
</NhostProvider>
|
||||
),
|
||||
|
||||
@@ -1,5 +1,153 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.37.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- cc98f33: fix: rename filename typo
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 25c0ffa: fix (dashboard): Parse tablename correctly into SQL query
|
||||
- 8812d9d: feat (dsashboard): Simplyfy column and row controls in database view
|
||||
|
||||
## 2.36.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- a30da08: feat (dashboard): add custom types to column types
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 73a7ba8: fix (dashboard): Show errors in row permission rule form
|
||||
- 397bfc9: fix (dashboard): Parse foreign key relations correctly
|
||||
- 2f4b376: fix (dashboard): allow permission variables with in operator
|
||||
- 88836f3: fix (dashboard): use correct fallback endpoint for migration in the CLI
|
||||
- ba3c49e: fix (dashboard): Show nested relationships in row permissions
|
||||
- 92e71a6: fix: minor fixes to csp
|
||||
- 81716d9: fix (dashboard): Show validation error on save when editing database columns
|
||||
|
||||
## 2.35.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7633d04: feat (dashbord): Allow composite primary keys
|
||||
- c4f383f: fix: dashboard: don't allow for upgrading to starter
|
||||
- 4c6400f: fix: handle redirect to verify email page if sign in with github
|
||||
- 7f0db21: feat: added entraid support
|
||||
- 412692c: chore (dashboard): Turn on strictNullChecks config
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 1708578: fix (dashboard): Update navbar after org and project operations
|
||||
- 34ede5c: fix: enable csp again
|
||||
- 96228df: chore (dashboard): update nhost-js to the latest version
|
||||
- d8c5117: fix (dashboard): Allow creating tables without primary key
|
||||
- 89f6fe6: chore (docker-example): update dashboard image version
|
||||
- e8a3789: fix (dashboard): scroll to active element in navbar when navigating
|
||||
|
||||
## 2.34.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7eb9539: feat (dashboard): Allow upgrading free organizations
|
||||
- 129ec1e: feat: dashboard: new onboarding
|
||||
- 59249e5: fix: elevate permissions in password reset
|
||||
- 5e9ddb4: fix: show Run service name in logs page
|
||||
- 4ffff86: fix (dashboard): Disable settings pages when config server env variable is not set
|
||||
- b8cb491: fix: update dependencies to fix vulnerabilities
|
||||
- 5565451: fix: support page, can scroll all the way down in Chrome for iOS
|
||||
- f7d7080: chore: dashboard: add gtag
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 181c0ab: fix (dashboard): Fix upgrade project e2e tests
|
||||
- 56c87da: fix (dashboard): Use the correct http method when conneting to new github
|
||||
- 00132bd: fix (dashboard): Clear isSigningOut variable on Signin page
|
||||
- 66e0cc8: fix (dashboard): Check if user is logged in before redirecting
|
||||
- 9c0a118: chore (dashboard): Add RetryLink to ApolloClient
|
||||
- df6b85e: fix (dashboard): fix password reset redirect url
|
||||
- ec24567: fix (dashboard): Add content-type header
|
||||
- 57b2615: chore (dashboard): refactor redirect behaviour
|
||||
- cffa161: fix (dashboard): disable settings in the header when self-hosting
|
||||
- 85316e8: fix (dashboard): Remove second loading indicator on projects page
|
||||
- 47ab341: fix (dashboard): Fix announcement layout when title is too short
|
||||
|
||||
## 2.33.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- aee9a80: chore: update typescript version to the latest
|
||||
- 5ef3f76: chore (dashboard): Use the new SDK in the Dashboard
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 9ed8ce8: fix (dashboard): Request new Mfa ticket after an invalid totp when signing in
|
||||
- fd3b5c7: fix (dashboard): Limit new project's name to a maximum of 32 charachters in E2E tests
|
||||
|
||||
## 2.32.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 736862c: fix: update link to base directory docs in git settings
|
||||
- ea99fb3: chore: dashboard: improve messaging when git connected
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d738884: chore (dashboard): Add link about antivirus integration
|
||||
|
||||
## 2.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 39b10a2: feat (dashboard): Add multi-factor authentication
|
||||
- 4b84780: feat (dashboard): Add Webauthn to dashboard
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 61eb6cd: fix (dashboard): Fix update project e2e test
|
||||
- @nhost/react-apollo@18.0.0
|
||||
- @nhost/nextjs@2.2.8
|
||||
|
||||
## 2.30.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- f6947a2: fix: fetch job-backup services logs using Live filter
|
||||
- 44a3e6b: fix: collapsed main navigation sidebar overlaps mobile navbar
|
||||
- 99b78f1: feat: dashboard: add download button for soc2 report
|
||||
- 9acae7d: fix: e2e tests, stop on error when refreshing metadata
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 31e636a: fix (dashboard): Use the correct payload to reset metadata before the e2e tests
|
||||
|
||||
## 2.29.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
- a0931e2: fix: improve logs time range and filter selection
|
||||
- c0635ae: feat (dashboard): Add information about that free organization cannot be upgraded.
|
||||
- e87505c: fix: can downsize postgres storage capacity using local dashboard
|
||||
|
||||
## 2.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8552678: feat: dashboard: add additional events to segment
|
||||
- 0bf2808: chore: refresh metadata before end-to-end tests
|
||||
- 72a365c: fix: correct graphql page roles dropdown's source
|
||||
- cef6471: fix: dashboard: add anonid to user's metadata
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- 233232b: feat (dashboard): improve Upgrade project dialog
|
||||
- Updated dependencies [d9eb906]
|
||||
- @nhost/nextjs@2.2.7
|
||||
- @nhost/react-apollo@17.0.4
|
||||
|
||||
## 2.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -21,6 +21,6 @@ find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_STORAGE_URL__~${NEXT_
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL__~${NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_HASURA_API_URL__~${NEXT_PUBLIC_NHOST_HASURA_API_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__~${NEXT_PUBLIC_NHOST_CONFIGSERVER_URL}~g" {} +
|
||||
find dashboard -type f -exec sed -i "s~__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__~${NEXT_PUBLIC_NHOST_CONFIGSERVER_URL:-""}~g" {} +
|
||||
|
||||
exec "$@"
|
||||
|
||||
@@ -13,6 +13,9 @@ test('should be able to ban and unban a user', async ({
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
await page.waitForSelector(
|
||||
'div:has-text("User has been created successfully.")',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
|
||||
@@ -11,6 +11,9 @@ test('should create a user', async ({ authenticatedNhostPage: page }) => {
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
await page.waitForSelector(
|
||||
'div:has-text("User has been created successfully.")',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
|
||||
@@ -14,6 +14,9 @@ test('should be able to delete a user', async ({
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
await page.waitForSelector(
|
||||
'div:has-text("User has been created successfully.")',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
@@ -39,6 +42,7 @@ test('should be able to delete a user', async ({
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText('User deleted successfully.')).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to delete a user from the details page', async ({
|
||||
@@ -49,6 +53,10 @@ test('should be able to delete a user from the details page', async ({
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page.waitForSelector(
|
||||
'div:has-text("User has been created successfully.")',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
@@ -68,4 +76,5 @@ test('should be able to delete a user from the details page', async ({
|
||||
await expect(
|
||||
page.getByRole('button', { name: `View ${email}`, exact: true }),
|
||||
).not.toBeVisible();
|
||||
await expect(page.getByText('User deleted successfully.')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -13,6 +13,9 @@ test('should be able to edit user roles from the details page', async ({
|
||||
const password = faker.internet.password();
|
||||
|
||||
await createUser({ page, email, password });
|
||||
await page.waitForSelector(
|
||||
'div:has-text("User has been created successfully.")',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
|
||||
@@ -14,6 +14,10 @@ test('should be able to verify the email of a user', async ({
|
||||
|
||||
await createUser({ page, email, password });
|
||||
|
||||
await page.waitForSelector(
|
||||
'div:has-text("User has been created successfully.")',
|
||||
);
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||
.click();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { prepareTable } from '@/e2e/utils';
|
||||
import { prepareTable, toPascalCase } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
@@ -16,12 +16,12 @@ test('should create a simple table', async ({
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
const tableName = toPascalCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
@@ -38,6 +38,7 @@ test('should create a simple table', async ({
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('columnheader', { name: 'id' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with unique constraints', async ({
|
||||
@@ -51,7 +52,7 @@ test('should create a table with unique constraints', async ({
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text', unique: true },
|
||||
@@ -82,7 +83,7 @@ test('should create a table with nullable columns', async ({
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text', nullable: true },
|
||||
@@ -113,7 +114,7 @@ test('should create a table with an identity column', async ({
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'int4' },
|
||||
{ name: 'title', type: 'text', nullable: true },
|
||||
@@ -148,7 +149,7 @@ test('should create table with foreign key constraint', async ({
|
||||
await prepareTable({
|
||||
page,
|
||||
name: firstTableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
@@ -170,7 +171,7 @@ test('should create table with foreign key constraint', async ({
|
||||
await prepareTable({
|
||||
page,
|
||||
name: secondTableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
@@ -196,6 +197,10 @@ test('should create table with foreign key constraint', async ({
|
||||
|
||||
await page.getByRole('button', { name: /add/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByTestId('foreignKeyFormSubmitButton'),
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
page.getByText(`public.${firstTableName}.id`, { exact: true }),
|
||||
).toBeVisible();
|
||||
@@ -223,7 +228,7 @@ test('should not be able to create a table with a name that already exists', asy
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
@@ -243,7 +248,7 @@ test('should not be able to create a table with a name that already exists', asy
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
@@ -258,3 +263,33 @@ test('should not be able to create a table with a name that already exists', asy
|
||||
page.getByText(/error: a table with this name already exists/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to create a table with a composite key', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKeys: ['id', 'second_id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'second_id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ test('should delete a table', async ({ authenticatedNhostPage: page }) => {
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
@@ -59,7 +59,7 @@ test('should not be able to delete a table if other tables have foreign keys ref
|
||||
await prepareTable({
|
||||
page,
|
||||
name: firstTableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'name', type: 'text' },
|
||||
@@ -81,7 +81,7 @@ test('should not be able to delete a table if other tables have foreign keys ref
|
||||
await prepareTable({
|
||||
page,
|
||||
name: secondTableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
|
||||
@@ -21,7 +21,7 @@ test('should create a table with role permissions to select row', async ({
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
@@ -69,7 +69,7 @@ test('should create a table with role permissions and a custom check to select r
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
primaryKeys: ['id'],
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
|
||||
@@ -1,42 +1,46 @@
|
||||
/**
|
||||
* URL of the dashboard to test against.
|
||||
*/
|
||||
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL;
|
||||
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL!;
|
||||
|
||||
/**
|
||||
* Name of the organization to test against.
|
||||
*/
|
||||
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME;
|
||||
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME!;
|
||||
|
||||
/**
|
||||
* Slug of the organization to test against.
|
||||
*/
|
||||
export const TEST_ORGANIZATION_SLUG = process.env.NHOST_TEST_ORGANIZATION_SLUG;
|
||||
export const TEST_ORGANIZATION_SLUG = process.env.NHOST_TEST_ORGANIZATION_SLUG!;
|
||||
|
||||
/**
|
||||
* Name of the project to test against.
|
||||
*/
|
||||
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME;
|
||||
export const TEST_PROJECT_NAME = process.env.NHOST_TEST_PROJECT_NAME!;
|
||||
|
||||
/**
|
||||
* Subdomain of the project to test against.
|
||||
*/
|
||||
export const TEST_PROJECT_SUBDOMAIN = process.env.NHOST_TEST_PROJECT_SUBDOMAIN;
|
||||
export const TEST_PROJECT_SUBDOMAIN = process.env.NHOST_TEST_PROJECT_SUBDOMAIN!;
|
||||
|
||||
/**
|
||||
* Hasura admin secret of the test project to use.
|
||||
*/
|
||||
export const TEST_PROJECT_ADMIN_SECRET =
|
||||
process.env.NHOST_TEST_PROJECT_ADMIN_SECRET;
|
||||
process.env.NHOST_TEST_PROJECT_ADMIN_SECRET!;
|
||||
|
||||
/**
|
||||
* Email of the test account to use.
|
||||
*/
|
||||
export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL;
|
||||
export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL!;
|
||||
|
||||
/**
|
||||
* Password of the test account to use.
|
||||
*/
|
||||
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD;
|
||||
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD!;
|
||||
|
||||
export const TEST_PERSONAL_ORG_SLUG = process.env.NHOST_TEST_PERSONAL_ORG_SLUG;
|
||||
export const TEST_PERSONAL_ORG_SLUG = process.env.NHOST_TEST_PERSONAL_ORG_SLUG!;
|
||||
|
||||
const freeUserEmails = process.env.NHOST_TEST_FREE_USER_EMAILS!;
|
||||
|
||||
export const TEST_FREE_USER_EMAILS: string[] = JSON.parse(freeUserEmails);
|
||||
|
||||
@@ -10,10 +10,11 @@ export const test = base.extend<{ authenticatedNhostPage: Page }>({
|
||||
await page.goto('/');
|
||||
await page.waitForURL(
|
||||
`${TEST_DASHBOARD_URL}/orgs/${TEST_PERSONAL_ORG_SLUG}/projects`,
|
||||
{ waitUntil: 'networkidle' },
|
||||
{ waitUntil: 'load' },
|
||||
);
|
||||
await use(page);
|
||||
// update the context to get the new refresh token
|
||||
await page.waitForLoadState('load');
|
||||
await page.context().storageState({ path: AUTH_CONTEXT });
|
||||
await page.close();
|
||||
},
|
||||
|
||||
223
dashboard/e2e/onboarding/onboarding.test.ts
Normal file
223
dashboard/e2e/onboarding/onboarding.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import {
|
||||
getCardExpiration,
|
||||
getOrgSlugFromUrl,
|
||||
getProjectSlugFromUrl,
|
||||
gotoUrl,
|
||||
loginWithFreeUser,
|
||||
setFreeUserStarterOrgSlug,
|
||||
setNewProjectName,
|
||||
setNewProjectSlug,
|
||||
} from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
await loginWithFreeUser(page);
|
||||
});
|
||||
|
||||
test('user should be able to finish onboarding', async () => {
|
||||
await gotoUrl(page, `/onboarding`);
|
||||
expect(page.getByText('Welcome to Nhost!')).toBeVisible();
|
||||
const organizationName = faker.lorem.words(3).slice(0, 32);
|
||||
|
||||
await page.getByLabel('Organization Name').fill(organizationName);
|
||||
await page.getByText('Select organization type', { exact: true }).click();
|
||||
await page.getByText('Personal Project').nth(1).click();
|
||||
|
||||
await page.getByText('Pro', { exact: true }).click();
|
||||
|
||||
await page.getByText('Create Organization').click();
|
||||
|
||||
const stripeFrame = page
|
||||
.frameLocator('iframe[name="embedded-checkout"]')
|
||||
.first();
|
||||
stripeFrame.getByText('Subscribe to Nhost');
|
||||
await stripeFrame.getByLabel('Email').fill(faker.internet.email());
|
||||
|
||||
await stripeFrame
|
||||
.getByPlaceholder('1234 1234 1234 1234')
|
||||
.fill('4242424242424242');
|
||||
|
||||
await stripeFrame.getByPlaceholder('MM / YY').fill(getCardExpiration());
|
||||
await stripeFrame.getByPlaceholder('CVC').fill('123');
|
||||
await stripeFrame
|
||||
.getByPlaceholder('Full name on card')
|
||||
.fill('EndyTo EndyTest');
|
||||
await stripeFrame.locator('#billingCountry').scrollIntoViewIfNeeded();
|
||||
// Need to comment out for testing outside US START
|
||||
// await stripeFrame.getByPlaceholder('Address', { exact: true }).click();
|
||||
// stripeFrame.locator('span:has-text("Enter address manually")');
|
||||
// await stripeFrame.getByText('Enter address manually').click();
|
||||
// await stripeFrame
|
||||
// .getByPlaceholder('Address line 1', { exact: true })
|
||||
// .fill('123 Main Street');
|
||||
// await stripeFrame
|
||||
// .getByPlaceholder('City', { exact: true })
|
||||
// .fill('Springfield');
|
||||
// await stripeFrame.getByPlaceholder('ZIP', { exact: true }).fill('62701');
|
||||
// await stripeFrame.locator('#enableStripePass').click({ force: true });
|
||||
// Need to comment out for testing outside US END
|
||||
stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
.scrollIntoViewIfNeeded();
|
||||
await stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
.click({ force: true });
|
||||
|
||||
expect(
|
||||
page.getByText('Processing new organization request').first(),
|
||||
).toBeVisible();
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Organization created successfully. Redirecting...")',
|
||||
);
|
||||
|
||||
expect(page.getByText('Create Your First Project')).toBeVisible();
|
||||
|
||||
const projectName = faker.lorem.words(3).slice(0, 32);
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
|
||||
await page.getByText('Create Project', { exact: true }).click();
|
||||
|
||||
expect(page.getByText('Creating your project...')).toBeVisible();
|
||||
expect(page.getByText('Project created successfully!')).toBeVisible();
|
||||
|
||||
expect(page.getByText('Internal info')).toBeVisible();
|
||||
|
||||
await page.waitForSelector('h3:has-text("Project Health")', {
|
||||
timeout: 180000,
|
||||
});
|
||||
|
||||
const newProjectSlug = getProjectSlugFromUrl(page.url());
|
||||
setNewProjectSlug(newProjectSlug);
|
||||
setNewProjectName(organizationName);
|
||||
const newOrgSlug = getOrgSlugFromUrl(page.url());
|
||||
setFreeUserStarterOrgSlug(newOrgSlug);
|
||||
});
|
||||
|
||||
test('should delete the new organization', async () => {
|
||||
const newOrgSlug = getOrgSlugFromUrl(page.url());
|
||||
await gotoUrl(page, `/orgs/${newOrgSlug}/projects`);
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
|
||||
await page.waitForSelector('h3:has-text("Delete Organization")');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Delete Organization")');
|
||||
expect(page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
|
||||
await page.getByLabel("I'm sure I want to delete this Organization").click();
|
||||
expect(page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
await page.getByLabel('I understand this action cannot be undone').click();
|
||||
expect(page.getByTestId('deleteOrgButton')).not.toBeDisabled();
|
||||
|
||||
await page.getByTestId('deleteOrgButton').click();
|
||||
|
||||
await page.waitForSelector('div:has-text("Deleting the organization")');
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Successfully deleted the organization")',
|
||||
);
|
||||
|
||||
await page.waitForSelector('h2:has-text("Welcome to Nhost!")');
|
||||
});
|
||||
|
||||
test('should be able to upgrade an organization', async () => {
|
||||
await gotoUrl(page, `/onboarding`);
|
||||
expect(page.getByText('Welcome to Nhost!')).toBeVisible();
|
||||
const organizationName = faker.lorem.words(3).slice(0, 32);
|
||||
|
||||
await page.getByLabel('Organization Name').fill(organizationName);
|
||||
await page.getByText('Select organization type', { exact: true }).click();
|
||||
await page.getByText('Personal Project').nth(1).click();
|
||||
|
||||
await page.getByText('Create Organization').click();
|
||||
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Organization created successfully!")',
|
||||
);
|
||||
await page.getByText('Select organization', { exact: true }).click();
|
||||
await page.getByLabel('Organizations').getByText(organizationName).click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Welcome to Nhost!")');
|
||||
await page.getByRole('link', { name: 'Billing' }).click();
|
||||
|
||||
await page.waitForSelector('h4:has-text("Subscription plan")');
|
||||
expect(page.getByText('Upgrade')).toBeEnabled();
|
||||
await page.getByText('Upgrade').click();
|
||||
await page.waitForSelector('h2:has-text("Upgrade Organization")');
|
||||
|
||||
await page.getByText('Pro', { exact: true }).click();
|
||||
|
||||
await page.getByTestId('upgradeOrgSubmitButton').click();
|
||||
await page.waitForSelector('button[data-testid="upgradeOrgSubmitButton"]', {
|
||||
state: 'hidden',
|
||||
});
|
||||
|
||||
const stripeFrame = page
|
||||
.frameLocator('iframe[name="embedded-checkout"]')
|
||||
.first();
|
||||
stripeFrame
|
||||
.locator('div[data-testid="product-summary"]')
|
||||
.waitFor({ state: 'visible' });
|
||||
await stripeFrame.getByLabel('Email').fill(faker.internet.email());
|
||||
|
||||
await stripeFrame
|
||||
.getByPlaceholder('1234 1234 1234 1234')
|
||||
.fill('4242424242424242');
|
||||
|
||||
await stripeFrame.getByPlaceholder('MM / YY').fill(getCardExpiration());
|
||||
await stripeFrame.getByPlaceholder('CVC').fill('123');
|
||||
await stripeFrame
|
||||
.getByPlaceholder('Full name on card')
|
||||
.fill('EndyTo EndyTest');
|
||||
await stripeFrame.locator('#billingCountry').scrollIntoViewIfNeeded();
|
||||
// Need to comment out for testing outside US START
|
||||
// await stripeFrame.getByPlaceholder('Address', { exact: true }).click();
|
||||
// stripeFrame.locator('span:has-text("Enter address manually")');
|
||||
// await stripeFrame.getByText('Enter address manually').click();
|
||||
// await stripeFrame
|
||||
// .getByPlaceholder('Address line 1', { exact: true })
|
||||
// .fill('123 Main Street');
|
||||
// await stripeFrame
|
||||
// .getByPlaceholder('City', { exact: true })
|
||||
// .fill('Springfield');
|
||||
// await stripeFrame.getByPlaceholder('ZIP', { exact: true }).fill('62701');
|
||||
// await stripeFrame.locator('#enableStripePass').click({ force: true });
|
||||
// Need to comment out for testing outside US END
|
||||
stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
.scrollIntoViewIfNeeded();
|
||||
await stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
.click({ force: true });
|
||||
await page.waitForSelector('div:has-text("Upgrading organization")');
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Organization has been upgraded successfully.")',
|
||||
);
|
||||
await page.waitForSelector('span:has-text("Spending Notifications")');
|
||||
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
|
||||
await page.waitForSelector('h3:has-text("Delete Organization")');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Delete Organization")');
|
||||
expect(page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
|
||||
await page.getByLabel("I'm sure I want to delete this Organization").click();
|
||||
expect(page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
await page.getByLabel('I understand this action cannot be undone').click();
|
||||
expect(page.getByTestId('deleteOrgButton')).not.toBeDisabled();
|
||||
|
||||
await page.getByTestId('deleteOrgButton').click();
|
||||
|
||||
await page.waitForSelector('div:has-text("Deleting the organization")');
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Successfully deleted the organization")',
|
||||
);
|
||||
|
||||
await page.waitForSelector('h2:has-text("Welcome to Nhost!")');
|
||||
});
|
||||
@@ -18,7 +18,7 @@ setup('authenticate user', async ({ page }) => {
|
||||
|
||||
await page.waitForURL(
|
||||
`${TEST_DASHBOARD_URL}/orgs/${TEST_PERSONAL_ORG_SLUG}/projects`,
|
||||
{ waitUntil: 'networkidle' },
|
||||
{ waitUntil: 'load' },
|
||||
);
|
||||
await page.context().storageState({ path: 'e2e/.auth/user.json' });
|
||||
});
|
||||
|
||||
55
dashboard/e2e/setup/refresh-metadata.setup.ts
Normal file
55
dashboard/e2e/setup/refresh-metadata.setup.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/* eslint-disable no-console */
|
||||
import { TEST_PROJECT_ADMIN_SECRET, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup('refresh metadata', async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://${TEST_PROJECT_SUBDOMAIN}.hasura.eu-central-1.staging.nhost.run/v1/metadata`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': TEST_PROJECT_ADMIN_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args: [
|
||||
{
|
||||
type: 'reload_metadata',
|
||||
args: {
|
||||
reload_sources: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
args: {},
|
||||
type: 'get_inconsistent_metadata',
|
||||
},
|
||||
],
|
||||
source: 'default',
|
||||
type: 'bulk',
|
||||
}),
|
||||
},
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const message = `[${body.code}]:${body.error}`;
|
||||
throw new Error(message);
|
||||
} else {
|
||||
const isConsistent = body[0].is_consistent;
|
||||
if (isConsistent) {
|
||||
console.log('Metadata is consistent.');
|
||||
} else {
|
||||
console.error('Metadata is not consistent.');
|
||||
console.error(body[0].inconsistent_objects);
|
||||
throw new Error('Metadata is not consistent');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Log safe error information
|
||||
console.error(
|
||||
'Failed to refresh metadata:',
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
throw new Error('Failed to refresh metadata');
|
||||
}
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import {
|
||||
TEST_FREE_USER_EMAILS,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
TEST_USER_PASSWORD,
|
||||
} from '@/e2e/env';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { type Page } from '@playwright/test';
|
||||
import { add, format } from 'date-fns-v4';
|
||||
|
||||
/**
|
||||
* Open a project by navigating to the project's overview page.
|
||||
@@ -22,7 +28,7 @@ export async function navigateToProject({
|
||||
const projectUrl = `/orgs/${orgSlug}/projects/${projectSubdomain}`;
|
||||
|
||||
try {
|
||||
await page.goto(projectUrl, { waitUntil: 'networkidle' });
|
||||
await page.goto(projectUrl, { waitUntil: 'load' });
|
||||
await page.waitForURL(projectUrl, { timeout: 10000 });
|
||||
} catch (error) {
|
||||
console.error(`Failed to navigate to project URL: ${projectUrl}`, error);
|
||||
@@ -40,12 +46,12 @@ export async function navigateToProject({
|
||||
export async function prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey,
|
||||
primaryKeys,
|
||||
columns,
|
||||
}: {
|
||||
page: Page;
|
||||
name: string;
|
||||
primaryKey: string;
|
||||
primaryKeys: string[];
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
@@ -54,7 +60,7 @@ export async function prepareTable({
|
||||
defaultValue?: string;
|
||||
}>;
|
||||
}) {
|
||||
if (!columns.some(({ name }) => name === primaryKey)) {
|
||||
if (!columns.some(({ name }) => primaryKeys.includes(name))) {
|
||||
throw new Error('Primary key must be one of the columns.');
|
||||
}
|
||||
|
||||
@@ -118,11 +124,13 @@ export async function prepareTable({
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
// select the first column as primary key
|
||||
// await page.getByRole('button', { name: /primary key/i }).click();
|
||||
await page.getByLabel('Primary Key').click();
|
||||
await page.getByRole('option', { name: primaryKey, exact: true }).click();
|
||||
await Promise.all(
|
||||
primaryKeys.map(async (primaryKey) => {
|
||||
await page.getByRole('option', { name: primaryKey, exact: true }).click();
|
||||
}),
|
||||
);
|
||||
await page.getByText('Create a New Table').click();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,8 +221,101 @@ export async function clickPermissionButton({
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function gotoAuthURL(page) {
|
||||
export async function gotoAuthURL(page: Page) {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
await page.waitForURL(authUrl, { waitUntil: 'load' });
|
||||
}
|
||||
|
||||
export async function gotoUrl(page: Page, url: string) {
|
||||
await page.goto(url);
|
||||
await page.waitForURL(url, { waitUntil: 'load' });
|
||||
}
|
||||
|
||||
let newOrgSlug: string;
|
||||
|
||||
export function getNewOrgSlug() {
|
||||
return newOrgSlug;
|
||||
}
|
||||
|
||||
export function setNewOrgSlug(slug: string) {
|
||||
newOrgSlug = slug;
|
||||
}
|
||||
|
||||
let freeUserStarterOrgSlug: string;
|
||||
|
||||
export function getFreeUserStarterOrgSlug() {
|
||||
return freeUserStarterOrgSlug;
|
||||
}
|
||||
|
||||
export function setFreeUserStarterOrgSlug(slug: string) {
|
||||
freeUserStarterOrgSlug = slug;
|
||||
}
|
||||
|
||||
let newProjectSlug: string;
|
||||
|
||||
export function getNewProjectSlug() {
|
||||
return newProjectSlug;
|
||||
}
|
||||
|
||||
export function setNewProjectSlug(slug: string) {
|
||||
newProjectSlug = slug;
|
||||
}
|
||||
|
||||
export function getProjectSlugFromUrl(url: string) {
|
||||
const [, projectSlug] = url.split('/projects/');
|
||||
|
||||
return projectSlug;
|
||||
}
|
||||
|
||||
export function getOrgSlugFromUrl(url: string) {
|
||||
const orgSlug = url.split('/orgs/')[1].split('/projects/')[0];
|
||||
return orgSlug;
|
||||
}
|
||||
|
||||
export function getCardExpiration() {
|
||||
const now = add(new Date(), { years: 3 });
|
||||
return format(now, 'MMyy');
|
||||
}
|
||||
|
||||
let newProjectName: string;
|
||||
|
||||
export function getNewProjectName() {
|
||||
return newProjectName;
|
||||
}
|
||||
|
||||
export function setNewProjectName(name: string) {
|
||||
newProjectName = name;
|
||||
}
|
||||
|
||||
function getRandomUserIndex(): number {
|
||||
return Math.floor(Math.random() * TEST_FREE_USER_EMAILS.length);
|
||||
}
|
||||
|
||||
export async function loginWithFreeUser(page: Page) {
|
||||
const userIndex = getRandomUserIndex();
|
||||
|
||||
const freeUserEmail = TEST_FREE_USER_EMAILS[userIndex];
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Selected userIndex: ${userIndex}`);
|
||||
await page.goto('/');
|
||||
await page.waitForURL('/signin');
|
||||
await page.getByRole('link', { name: /continue with email/i }).click();
|
||||
|
||||
await page.waitForURL('/signin/email');
|
||||
await page.getByLabel('Email').fill(freeUserEmail);
|
||||
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Welcome to Nhost!")', {
|
||||
timeout: 20000,
|
||||
});
|
||||
}
|
||||
|
||||
export function toPascalCase(str: string, divider = ' ') {
|
||||
return str
|
||||
.split(divider)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -5,21 +5,24 @@ const withBundleAnalyzer = require('@next/bundle-analyzer')({
|
||||
const { version } = require('./package.json');
|
||||
|
||||
const cspHeader = `
|
||||
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
||||
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com;
|
||||
default-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run;
|
||||
script-src 'self' 'unsafe-eval' cdn.segment.com js.stripe.com challenges.cloudflare.com googletagmanager.com;
|
||||
connect-src 'self' *.nhost.run wss://*.nhost.run nhost.run wss://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com;
|
||||
style-src 'self' 'unsafe-inline';
|
||||
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
||||
img-src 'self' blob: data: github.com avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
||||
font-src 'self' data:;
|
||||
object-src 'none';
|
||||
base-uri 'self';
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
frame-src 'self' js.stripe.com;
|
||||
frame-src 'self' js.stripe.com challenges.cloudflare.com;
|
||||
block-all-mixed-content;
|
||||
upgrade-insecure-requests;
|
||||
`;
|
||||
|
||||
if (process.env.NEXT_PUBLIC_ENV !== 'development') {
|
||||
cspHeader.concat(` upgrade-insecure-requests;`);
|
||||
}
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
reactStrictMode: false,
|
||||
swcMinify: false,
|
||||
@@ -38,10 +41,10 @@ module.exports = withBundleAnalyzer({
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
// {
|
||||
// key: 'Content-Security-Policy',
|
||||
// hgvalue: cspHeader.replace(/\s+/g, ' ').trim(),
|
||||
// },
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: cspHeader.replace(/\s+/g, ' ').trim(),
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'DENY',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.27.0",
|
||||
"version": "2.37.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -16,8 +16,10 @@
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
|
||||
"e2e-local": "pnpm install-browsers && pnpm playwright test --config=playwright.local.config.ts"
|
||||
"e2e:tests": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts -x",
|
||||
"e2e": "pnpm e2e:tests --project=main",
|
||||
"e2e:local": "pnpm e2e:tests --project=local",
|
||||
"e2e:onboarding": "pnpm e2e:tests --project=onboarding"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.9",
|
||||
@@ -37,29 +39,30 @@
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@icons-pack/react-simple-icons": "^9.6.0",
|
||||
"@marsidev/react-turnstile": "^1.0.2",
|
||||
"@mui/base": "5.0.0-beta.31",
|
||||
"@mui/material": "^5.15.14",
|
||||
"@mui/system": "^5.15.14",
|
||||
"@mui/x-date-pickers": "^5.0.20",
|
||||
"@nhost/nextjs": "workspace:*",
|
||||
"@nhost/react-apollo": "workspace:*",
|
||||
"@nhost/nhost-js-beta": "npm:@nhost/nhost-js@5.0.0-beta.9",
|
||||
"@radix-ui/react-accordion": "^1.2.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-checkbox": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.3",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
"@radix-ui/react-hover-card": "^1.1.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@radix-ui/react-popover": "^1.1.3",
|
||||
"@radix-ui/react-progress": "^1.1.0",
|
||||
"@radix-ui/react-radio-group": "^1.2.0",
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@segment/analytics-next": "^1.77.0",
|
||||
"@simplewebauthn/browser": "^9.0.1",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^1.54.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -85,9 +88,10 @@
|
||||
"graphql-tag": "^2.12.6",
|
||||
"graphql-ws": "^5.16.0",
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.31",
|
||||
"next-nprogress-bar": "^2.3.13",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -95,7 +99,7 @@
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "18.2.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-complex-tree": "^2.4.5",
|
||||
"react-complex-tree": "^2.6.0",
|
||||
"react-day-picker": "9.6.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
@@ -105,7 +109,7 @@
|
||||
"react-is": "18.2.0",
|
||||
"react-loading-skeleton": "^2.2.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-merge-refs": "^1.1.0",
|
||||
"react-merge-refs": "^3.0.2",
|
||||
"react-resizable-layout": "^0.7.2",
|
||||
"react-table": "^7.8.0",
|
||||
"recoil": "^0.7.7",
|
||||
@@ -116,6 +120,7 @@
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"test@latest": "link:playwright/test@latest",
|
||||
"timezones-list": "^3.1.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^9.0.1",
|
||||
@@ -194,7 +199,7 @@
|
||||
"tailwindcss": "^3.4.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.4.17",
|
||||
"vite": "^5.4.20",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ dotenv.config({ path: path.resolve(__dirname, '.env.test') });
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 60 * 1000,
|
||||
timeout: 120 * 1000,
|
||||
expect: {
|
||||
timeout: 10000,
|
||||
},
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
trace: 'retain-on-failure',
|
||||
baseURL: process.env.NHOST_TEST_DASHBOARD_URL,
|
||||
launchOptions: {
|
||||
slowMo: 500,
|
||||
@@ -34,13 +34,28 @@ export default defineConfig({
|
||||
testMatch: ['**/teardown/*.teardown.ts'],
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'main',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
grepInvert: [/Local Dashboard CLI e2e tests/],
|
||||
testIgnore: ['onboarding.test.ts', 'cli-local-dashboard.test.ts'],
|
||||
},
|
||||
{
|
||||
name: 'local',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: '', // Local dashboard URL
|
||||
},
|
||||
testMatch: 'cli-local-dashboard.test.ts',
|
||||
},
|
||||
{
|
||||
name: 'onboarding',
|
||||
testMatch: 'onboarding.test.ts',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
baseURL: '', // Local dashboard URL
|
||||
launchOptions: {
|
||||
slowMo: 500,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
testMatch: ['**/e2e/cli-local-dashboard/**'],
|
||||
},
|
||||
],
|
||||
});
|
||||
12
dashboard/public/assets/brands/entraid.svg
Normal file
12
dashboard/public/assets/brands/entraid.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
12
dashboard/public/assets/brands/light/azuread.svg
Normal file
12
dashboard/public/assets/brands/light/azuread.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
12
dashboard/public/assets/brands/light/entraid.svg
Normal file
12
dashboard/public/assets/brands/light/entraid.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 16 KiB |
59
dashboard/src/components/auth/SignInRightColumn.tsx
Normal file
59
dashboard/src/components/auth/SignInRightColumn.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import Image from 'next/image';
|
||||
|
||||
export function SignInRightColumn() {
|
||||
return (
|
||||
<div className="grid gap-6 font-[Inter]">
|
||||
<div className="text-center">
|
||||
<h2 className="mb-2 text-2xl font-semibold text-white">
|
||||
Ship 10x faster
|
||||
</h2>
|
||||
<p className="text-sm text-[#A2B3BE]">
|
||||
Skip months of backend setup and focus on building what matters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src="/assets/signup/CircleWavyCheck.svg"
|
||||
width={20}
|
||||
height={20}
|
||||
alt="Check"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-white">
|
||||
From idea to production
|
||||
</h3>
|
||||
<p className="text-xs text-[#A2B3BE]">
|
||||
Everything you need to ship fast, without the setup complexity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-gradient-to-r from-[#0052CD]/10 to-[#FF02F5]/10 p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex-shrink-0">
|
||||
<Image
|
||||
src="/assets/key.svg"
|
||||
width={20}
|
||||
height={20}
|
||||
alt="Security"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-semibold text-white">
|
||||
Sleep easy at night
|
||||
</h3>
|
||||
<p className="text-xs text-[#A2B3BE]">
|
||||
Rock-solid security so you can focus on building, not
|
||||
vulnerabilities.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { useSSRLocalStorage } from '@/hooks/useSSRLocalStorage';
|
||||
import { X } from 'lucide-react';
|
||||
import NextLink from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface CookieConsentProps {
|
||||
onAccept: () => void;
|
||||
}
|
||||
|
||||
export default function CookieConsent({ onAccept }: CookieConsentProps) {
|
||||
const [consentGiven, setConsentGiven] = useSSRLocalStorage<boolean | null>(
|
||||
'cookie-consent',
|
||||
null,
|
||||
);
|
||||
const [showBanner, setShowBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (consentGiven === null) {
|
||||
setShowBanner(true);
|
||||
} else if (consentGiven === true) {
|
||||
onAccept();
|
||||
}
|
||||
}, [consentGiven, onAccept]);
|
||||
|
||||
const handleAccept = () => {
|
||||
setConsentGiven(true);
|
||||
setShowBanner(false);
|
||||
onAccept();
|
||||
};
|
||||
|
||||
const handleDecline = () => {
|
||||
setConsentGiven(false);
|
||||
setShowBanner(false);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
handleDecline();
|
||||
};
|
||||
|
||||
if (!showBanner) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 w-96">
|
||||
<div className="rounded-lg border bg-black/95 p-6 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="mb-3 text-sm font-semibold text-white">
|
||||
We use cookies for payments and analytics to improve our services.
|
||||
</h3>
|
||||
<p className="mb-4 text-xs text-[#A2B3BE]">
|
||||
<NextLink
|
||||
href="https://nhost.io/legal/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white underline hover:no-underline"
|
||||
>
|
||||
Learn more
|
||||
</NextLink>
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleAccept}
|
||||
size="sm"
|
||||
className="bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-700"
|
||||
>
|
||||
Accept all
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDecline}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-gray-600 px-3 py-1 text-xs text-white hover:bg-gray-800"
|
||||
>
|
||||
Essential only
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
type="button"
|
||||
className="text-[#A2B3BE] hover:text-white"
|
||||
aria-label="Close cookie banner"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
2
dashboard/src/components/common/CookieConsent/index.ts
Normal file
2
dashboard/src/components/common/CookieConsent/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as CookieConsent } from './CookieConsent';
|
||||
|
||||
@@ -64,29 +64,29 @@ describe('DateTimePicker', () => {
|
||||
await screen.findByRole('button', { name: 'Select' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
expect(screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Next Month' }),
|
||||
);
|
||||
expect(screen.getByText('April 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('13'));
|
||||
await user.click(screen.getByText('13'));
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
const hoursInput = screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '11');
|
||||
|
||||
const minutesInput = await screen.getByLabelText('Minutes');
|
||||
const minutesInput = screen.getByLabelText('Minutes');
|
||||
await user.type(minutesInput, '12');
|
||||
|
||||
const secondsInput = await screen.getByLabelText('Seconds');
|
||||
const secondsInput = screen.getByLabelText('Seconds');
|
||||
await user.type(secondsInput, '13');
|
||||
|
||||
user.click(await screen.getByRole('button', { name: 'Select' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Select' }));
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.queryByRole('button', { name: 'Select' }),
|
||||
screen.queryByRole('button', { name: 'Select' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('DateTimePicker', () => {
|
||||
await user.type(tzInput, 'America/Chicago{ArrowDown}{Enter}');
|
||||
|
||||
expect(
|
||||
await screen.queryByPlaceholderText('Search timezones...'),
|
||||
screen.queryByPlaceholderText('Search timezones...'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
@@ -148,7 +148,7 @@ describe('DateTimePicker', () => {
|
||||
'Timezone: UTC+02:00',
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
expect(screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Next Month' }),
|
||||
@@ -156,7 +156,7 @@ describe('DateTimePicker', () => {
|
||||
|
||||
expect(screen.getByText('April 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('18'));
|
||||
await user.click(screen.getByText('18'));
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+03:00',
|
||||
@@ -166,9 +166,9 @@ describe('DateTimePicker', () => {
|
||||
screen.getByRole('button', { name: 'Go to the Previous Month' }),
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
expect(screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('21'));
|
||||
await user.click(screen.getByText('21'));
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+02:00',
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface DialogContextProps {
|
||||
/**
|
||||
* Call this function to open an alert dialog.
|
||||
*/
|
||||
openAlertDialog: <TPayload = string>(config?: DialogConfig<TPayload>) => void;
|
||||
openAlertDialog: <TPayload = string>(config: DialogConfig<TPayload>) => void;
|
||||
/**
|
||||
* Call this function to close the active dialog.
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,10 @@ import {
|
||||
drawerReducer,
|
||||
} from './dialogReducers';
|
||||
|
||||
function isBaseSyntheticEvent(event: any): event is BaseSyntheticEvent {
|
||||
return event?.type !== undefined;
|
||||
}
|
||||
|
||||
function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
const router = useRouter();
|
||||
|
||||
@@ -87,7 +91,7 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
drawerDispatch({ type: 'CLEAR_DRAWER_CONTENT' });
|
||||
}, []);
|
||||
|
||||
function openAlertDialog<TConfig = string>(config?: DialogConfig<TConfig>) {
|
||||
function openAlertDialog<TConfig = string>(config: DialogConfig<TConfig>) {
|
||||
alertDialogDispatch({ type: 'OPEN_ALERT', payload: config });
|
||||
}
|
||||
|
||||
@@ -122,8 +126,12 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
);
|
||||
|
||||
const closeDrawerWithDirtyGuard = useCallback(
|
||||
(event?: BaseSyntheticEvent) => {
|
||||
if (isDrawerDirty.current && event?.type !== 'submit') {
|
||||
(event?: any) => {
|
||||
if (
|
||||
isDrawerDirty.current &&
|
||||
isBaseSyntheticEvent(event) &&
|
||||
event.type !== 'submit'
|
||||
) {
|
||||
setShowDirtyConfirmation(true);
|
||||
openDirtyConfirmation({ props: { onPrimaryAction: closeDrawer } });
|
||||
return;
|
||||
@@ -135,8 +143,12 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
);
|
||||
|
||||
const closeDialogWithDirtyGuard = useCallback(
|
||||
(event?: BaseSyntheticEvent) => {
|
||||
if (isDialogDirty.current && event?.type !== 'submit') {
|
||||
(event?: any) => {
|
||||
if (
|
||||
isDialogDirty.current &&
|
||||
isBaseSyntheticEvent(event) &&
|
||||
event.type !== 'submit'
|
||||
) {
|
||||
setShowDirtyConfirmation(true);
|
||||
openDirtyConfirmation({ props: { onPrimaryAction: closeDialog } });
|
||||
return;
|
||||
@@ -250,7 +262,7 @@ function DialogProvider({ children }: PropsWithChildren<unknown>) {
|
||||
<BaseDialog
|
||||
{...dialogProps}
|
||||
title={dialogTitle}
|
||||
open={dialogOpen}
|
||||
open={!!dialogOpen}
|
||||
onClose={closeDialogWithDirtyGuard}
|
||||
TransitionProps={{ onExited: clearDialogContent, unmountOnExit: false }}
|
||||
PaperProps={{
|
||||
|
||||
@@ -115,7 +115,7 @@ export function drawerReducer(
|
||||
}
|
||||
|
||||
export type AlertDialogAction =
|
||||
| { type: 'OPEN_ALERT'; payload?: DialogConfig }
|
||||
| { type: 'OPEN_ALERT'; payload: DialogConfig }
|
||||
| { type: 'HIDE_ALERT' }
|
||||
| { type: 'CLEAR_ALERT_CONTENT' };
|
||||
|
||||
|
||||
518
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.test.tsx
Normal file
518
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.test.tsx
Normal file
@@ -0,0 +1,518 @@
|
||||
import { render, screen, TestUserEvent, waitFor } from '@/tests/testUtils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MfaOtpForm from './MfaOtpForm';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toastError: vi.fn(),
|
||||
}));
|
||||
// Mock react-hot-toast
|
||||
vi.mock('react-hot-toast', async () => {
|
||||
const actualToast = await vi.importActual<any>('react-hot-toast');
|
||||
return {
|
||||
...actualToast,
|
||||
default: {
|
||||
...actualToast.default,
|
||||
error: mocks.toastError,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the toast style props utility
|
||||
vi.mock('@/utils/constants/settings', () => ({
|
||||
getToastStyleProps: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
describe('MfaOtpForm', () => {
|
||||
const mockSendMfaOtp = vi.fn();
|
||||
const mockRequestNewMfaTicket = vi.fn();
|
||||
const user = new TestUserEvent();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllTimers();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
sendMfaOtp: mockSendMfaOtp,
|
||||
loading: false,
|
||||
requestNewMfaTicket: mockRequestNewMfaTicket,
|
||||
} as any;
|
||||
|
||||
describe('Rendering and Initial State', () => {
|
||||
it('renders with correct initial state', () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input).toHaveValue('');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
expect(input).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Input Validation and Formatting', () => {
|
||||
it('only accepts numeric characters', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
await user.type(input, 'abc123def456');
|
||||
|
||||
expect(input).toHaveValue('123456');
|
||||
});
|
||||
|
||||
it('filters out non-numeric characters in real time', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
await user.type(input, '1a2b3c');
|
||||
|
||||
expect(input).toHaveValue('123');
|
||||
});
|
||||
|
||||
it('button is disabled when input has fewer than 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '12345');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('button is enabled when input has exactly 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
expect(button).toBeEnabled();
|
||||
});
|
||||
|
||||
it('button is disabled when input has more than 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '6123457');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading States', () => {
|
||||
it('disables input and button when loading prop is true', () => {
|
||||
render(<MfaOtpForm {...defaultProps} loading />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button');
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(button).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Verifying...' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('input and button are disabled during submission', async () => {
|
||||
// Mock sendMfaOtp to return a promise that we can control
|
||||
const promise = new Promise(() => {}); // Never resolves
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(input).toBeDisabled();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('triggers sendMfaOtp with correct code on button click', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledWith('123456');
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not submit when input has fewer than 6 digits', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '12345');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit multiple times when already submitting', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
await user.click(button); // Second click should be ignored
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Resolve the promise to clean up
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('manages submission state properly', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
// During submission
|
||||
expect(button).toBeDisabled();
|
||||
expect(input).toBeDisabled();
|
||||
|
||||
// Resolve the promise
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
|
||||
// After submission
|
||||
await waitFor(() => {
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(input).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('displays error toast when sendMfaOtp returns an error', async () => {
|
||||
const errorMessage = 'Invalid TOTP code';
|
||||
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: errorMessage });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(errorMessage, {});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic error message when no specific error message is provided', async () => {
|
||||
mockSendMfaOtp.mockRejectedValueOnce({});
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'An error occurred. Please try again.',
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles undefined error gracefully', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({
|
||||
error: undefined,
|
||||
});
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
// Should not throw an error
|
||||
await waitFor(() => {
|
||||
expect(mockSendMfaOtp).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('MFA Ticket Renewal', () => {
|
||||
it('calls requestNewMfaTicket when ticket is invalid', async () => {
|
||||
// First call - set ticket as invalid
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: 'Invalid ticket' });
|
||||
// Second call - should work
|
||||
mockSendMfaOtp.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
// First submission - creates error and marks ticket invalid
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Clear input and try again
|
||||
await user.clear(input);
|
||||
await user.type(input, '654321');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRequestNewMfaTicket).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not call requestNewMfaTicket on first submission', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockRequestNewMfaTicket).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('works correctly when requestNewMfaTicket is not provided', async () => {
|
||||
const propsWithoutTicketRenewal = {
|
||||
sendMfaOtp: mockSendMfaOtp,
|
||||
loading: false,
|
||||
} as any;
|
||||
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: 'Some error' });
|
||||
|
||||
render(<MfaOtpForm {...propsWithoutTicketRenewal} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
// Should not throw an error even without requestNewMfaTicket
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('User Interactions', () => {
|
||||
it('updates input value correctly when typing', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123');
|
||||
expect(input).toHaveValue('123');
|
||||
|
||||
await user.type(input, '456');
|
||||
expect(input).toHaveValue('123456');
|
||||
});
|
||||
|
||||
it('can clear and retype input value', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
expect(input).toHaveValue('123456');
|
||||
|
||||
await user.clear(input);
|
||||
expect(input).toHaveValue('');
|
||||
|
||||
await user.type(input, '654321');
|
||||
expect(input).toHaveValue('654321');
|
||||
});
|
||||
|
||||
it('button triggers submission with valid code', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledWith('123456');
|
||||
});
|
||||
it('submits form when pressing Enter key with valid code', async () => {
|
||||
mockSendMfaOtp.mockResolvedValue({ success: true });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledWith('123456');
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not submit when pressing Enter with invalid code length', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '12345');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit when pressing Enter while loading', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} loading />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.type(input, '{Enter}');
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not submit multiple times when pressing Enter while submitting', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.type(input, '{Enter}');
|
||||
await user.type(input, '{Enter}'); // Second Enter should be ignored
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('handles null error message gracefully', async () => {
|
||||
mockSendMfaOtp.mockRejectedValueOnce({ message: null });
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toastError).toHaveBeenCalledWith(
|
||||
'An error occurred. Please try again.',
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('prevents multiple rapid submissions', async () => {
|
||||
let resolvePromise: (value: any) => void;
|
||||
const promise = new Promise((resolve) => {
|
||||
resolvePromise = resolve;
|
||||
});
|
||||
mockSendMfaOtp.mockReturnValue(promise);
|
||||
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Enter TOTP');
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.type(input, '123456');
|
||||
|
||||
// Rapid clicks
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Clean up
|
||||
resolvePromise!({ success: true });
|
||||
await waitFor(async () => {
|
||||
await promise;
|
||||
});
|
||||
});
|
||||
|
||||
it('handles empty input correctly', async () => {
|
||||
render(<MfaOtpForm {...defaultProps} />);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Verify' });
|
||||
|
||||
await user.click(button);
|
||||
|
||||
expect(mockSendMfaOtp).not.toHaveBeenCalled();
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
87
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.tsx
Normal file
87
dashboard/src/components/common/MfaOtpForm/MfaOtpForm.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Props {
|
||||
sendMfaOtp: (code: string) => Promise<any>;
|
||||
loading: boolean;
|
||||
requestNewMfaTicket?: () => Promise<void>;
|
||||
}
|
||||
|
||||
function MfaOtpForm({ sendMfaOtp, loading, requestNewMfaTicket }: Props) {
|
||||
const [otpValue, setOtpValue] = useState<string>('');
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const isMfaTicketInvalid = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function submitTOTP() {
|
||||
if (otpValue.length === 6 && !isSubmitting) {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (requestNewMfaTicket && isMfaTicketInvalid.current) {
|
||||
await requestNewMfaTicket();
|
||||
}
|
||||
await sendMfaOtp(otpValue);
|
||||
} catch (error) {
|
||||
isMfaTicketInvalid.current = true;
|
||||
toast.error(
|
||||
error?.message || 'An error occurred. Please try again.',
|
||||
getToastStyleProps(),
|
||||
);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 10);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const code = event.target.value.replace(/[^0-9]/g, '');
|
||||
setOtpValue(code);
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter') {
|
||||
submitTOTP();
|
||||
}
|
||||
}
|
||||
|
||||
const isInputDisabled = loading || isSubmitting;
|
||||
const isButtonDisabled = isInputDisabled || otpValue.length !== 6;
|
||||
|
||||
return (
|
||||
<div className="relative grid w-full grid-flow-row gap-4 bg-transparent">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={otpValue}
|
||||
placeholder="Enter TOTP"
|
||||
className="!bg-transparent"
|
||||
disabled={isInputDisabled}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Button disabled={isButtonDisabled} onClick={submitTOTP}>
|
||||
{loading ? 'Verifying...' : 'Verify'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MfaOtpForm;
|
||||
1
dashboard/src/components/common/MfaOtpForm/index.ts
Normal file
1
dashboard/src/components/common/MfaOtpForm/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as MfaOtpForm } from './MfaOtpForm';
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { LinkProps } from '@/components/ui/v2/Link';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import type { MakeRequired } from '@/types/common';
|
||||
import NextLink from 'next/link';
|
||||
import type { ForwardedRef, PropsWithoutRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface NavLinkProps extends PropsWithoutRef<LinkProps> {
|
||||
export interface NavLinkProps
|
||||
extends MakeRequired<PropsWithoutRef<LinkProps>, 'href'> {
|
||||
/**
|
||||
* Determines whether or not the link should be disabled.
|
||||
*/
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/use
|
||||
|
||||
interface Props {
|
||||
buttonText?: string;
|
||||
onClick?: () => void;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function OpenTransferDialogButton({ buttonText, onClick }: Props) {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type PaginationProps = DetailedHTMLProps<
|
||||
/**
|
||||
* Number of total elements per page.
|
||||
*/
|
||||
elementsPerPage?: number;
|
||||
elementsPerPage: number;
|
||||
/**
|
||||
* Total number of elements.
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,6 @@ import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import {} from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TimePickerInput } from './TimePickerInput';
|
||||
|
||||
interface TimePickerProps {
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
setDate: (date: Date) => void;
|
||||
}
|
||||
|
||||
function TimePicker({ date, setDate }: TimePickerProps) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface TimePickerInputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
picker: TimePickerType;
|
||||
date: Date | undefined;
|
||||
setDate: (date: Date | undefined) => void;
|
||||
setDate: (date: Date) => void;
|
||||
period?: Period;
|
||||
onRightFocus?: () => void;
|
||||
onLeftFocus?: () => void;
|
||||
|
||||
@@ -19,7 +19,7 @@ function getOrderedTimezones(dateTime: string, selectedTimezone: string) {
|
||||
) {
|
||||
const selectedTimezoneOption = timezones.find(
|
||||
(tz) => tz.value === selectedTimezone,
|
||||
);
|
||||
)!;
|
||||
orderedTimezones = [
|
||||
selectedTimezoneOption,
|
||||
...timezones.filter((tz) => tz.value !== selectedTimezone),
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface UIContextProps {
|
||||
/**
|
||||
* The date and time when maintenance mode will end.
|
||||
*/
|
||||
maintenanceEndDate: Date;
|
||||
maintenanceEndDate: Date | null;
|
||||
}
|
||||
|
||||
const UIContext = createContext<UIContextProps>({
|
||||
|
||||
@@ -24,7 +24,7 @@ type Option = {
|
||||
};
|
||||
|
||||
interface VirtualizedCommandProps<O extends Option> {
|
||||
height: string;
|
||||
height?: string;
|
||||
options: O[];
|
||||
placeholder: string;
|
||||
selectedOption: string;
|
||||
@@ -181,7 +181,7 @@ interface VirtualizedComboboxProps<O extends Option> {
|
||||
width?: string;
|
||||
height?: string;
|
||||
button?: React.JSX.Element;
|
||||
onSelectOption?: (option: O) => void;
|
||||
onSelectOption: (option: O) => void;
|
||||
selectedOption: string;
|
||||
align?: 'start' | 'center' | 'end';
|
||||
side?: 'right' | 'top' | 'bottom' | 'left';
|
||||
@@ -210,7 +210,7 @@ function VirtualizedCombobox<O extends Option>({
|
||||
}}
|
||||
>
|
||||
{selectedOption
|
||||
? options.find((option) => option.value === selectedOption).value
|
||||
? options.find((option) => option.value === selectedOption)!.value
|
||||
: searchPlaceholder}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
|
||||
@@ -3,12 +3,15 @@ import type {
|
||||
AutocompleteProps,
|
||||
} from '@/components/ui/v2/Autocomplete';
|
||||
import { Autocomplete } from '@/components/ui/v2/Autocomplete';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import type { MakeRequired } from '@/types/common';
|
||||
import { callAll } from '@/utils/callAll';
|
||||
import type { FilterOptionsState } from '@mui/material';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FieldValues, UseControllerProps } from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledAutocompleteProps<
|
||||
TOption extends AutocompleteOption = AutocompleteOption,
|
||||
@@ -28,6 +31,63 @@ export interface ControlledAutocompleteProps<
|
||||
control?: UseControllerProps<TFieldValues>['control'];
|
||||
}
|
||||
|
||||
export function defaultFilterOptions(
|
||||
options: AutocompleteOption<string>[],
|
||||
{ inputValue }: FilterOptionsState<AutocompleteOption<string>>,
|
||||
) {
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matched: AutocompleteOption<string>[] = [];
|
||||
const otherOptions: AutocompleteOption<string>[] = [];
|
||||
|
||||
options.forEach((option) => {
|
||||
const optionLabelLower = option.label.toLowerCase();
|
||||
|
||||
if (optionLabelLower.startsWith(inputValueLower)) {
|
||||
matched.push(option);
|
||||
} else {
|
||||
otherOptions.push(option);
|
||||
}
|
||||
});
|
||||
|
||||
const result = [...matched, ...otherOptions];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function defaultFilterGroupedOptions(
|
||||
options: AutocompleteOption<string>[],
|
||||
{ inputValue }: FilterOptionsState<AutocompleteOption<string>>,
|
||||
) {
|
||||
const optionsWithGroup = options as MakeRequired<
|
||||
AutocompleteOption<string>,
|
||||
'group'
|
||||
>[];
|
||||
const inputValueLower = inputValue.toLowerCase();
|
||||
const matchedSet = new Set<string>();
|
||||
const otherOptionsSet = new Set<string>();
|
||||
|
||||
optionsWithGroup.forEach((option) => {
|
||||
const optionLabelLower = option.label.toLowerCase();
|
||||
|
||||
if (optionLabelLower.includes(inputValueLower)) {
|
||||
matchedSet.add(option.group);
|
||||
otherOptionsSet.delete(option.group);
|
||||
} else if (!matchedSet.has(option.group)) {
|
||||
otherOptionsSet.add(option.group);
|
||||
}
|
||||
});
|
||||
const matchedOptions = optionsWithGroup.filter((option) =>
|
||||
matchedSet.has(option.group),
|
||||
);
|
||||
const otherOptions = optionsWithGroup.filter((option) =>
|
||||
otherOptionsSet.has(option.group),
|
||||
);
|
||||
|
||||
const result = [...matchedOptions, ...otherOptions];
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function ControlledAutocomplete(
|
||||
{
|
||||
controllerProps,
|
||||
@@ -38,9 +98,10 @@ function ControlledAutocomplete(
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
const form = useFormContext();
|
||||
const nameAttr = controllerProps?.name || name || '';
|
||||
const { field } = useController({
|
||||
...(controllerProps || {}),
|
||||
name: controllerProps?.name || name || '',
|
||||
name: nameAttr,
|
||||
control: controllerProps?.control || control,
|
||||
});
|
||||
|
||||
@@ -53,13 +114,15 @@ function ControlledAutocomplete(
|
||||
return (
|
||||
<Autocomplete
|
||||
inputValue={
|
||||
typeof field.value !== 'object' ? field.value.toString() : undefined
|
||||
typeof field.value !== 'object' && isNotEmptyValue(field.value)
|
||||
? field.value.toString()
|
||||
: undefined
|
||||
}
|
||||
{...props}
|
||||
{...field}
|
||||
ref={mergeRefs([field.ref, ref])}
|
||||
onChange={(event, options, reason, details) => {
|
||||
setValue?.(controllerProps?.name || name, options, {
|
||||
setValue?.(nameAttr, options, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
@@ -67,7 +130,10 @@ function ControlledAutocomplete(
|
||||
props.onChange(event, options, reason, details);
|
||||
}
|
||||
}}
|
||||
onBlur={callAll(field.onBlur, props.onBlur)}
|
||||
onBlur={callAll<[React.FocusEvent<HTMLDivElement>]>(
|
||||
field.onBlur,
|
||||
props.onBlur,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FieldValues, UseControllerProps } from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledCheckboxProps<TFieldValues extends FieldValues = any>
|
||||
extends CheckboxProps {
|
||||
@@ -38,12 +38,13 @@ function ControlledCheckbox(
|
||||
uncheckWhenDisabled,
|
||||
...props
|
||||
}: ControlledCheckboxProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setValue } = useFormContext();
|
||||
const nameAttr = controllerProps?.name || name || '';
|
||||
const { field } = useController({
|
||||
...controllerProps,
|
||||
name: controllerProps?.name || name || '',
|
||||
name: nameAttr,
|
||||
control: controllerProps?.control || control,
|
||||
});
|
||||
|
||||
@@ -53,13 +54,16 @@ function ControlledCheckbox(
|
||||
name={field.name}
|
||||
ref={mergeRefs([field.ref, ref])}
|
||||
onChange={(event, checked) => {
|
||||
setValue(controllerProps?.name || name, checked, { shouldDirty: true });
|
||||
setValue(nameAttr, checked, { shouldDirty: true });
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(event, checked);
|
||||
}
|
||||
}}
|
||||
onBlur={callAll(field.onBlur, props.onBlur)}
|
||||
onBlur={callAll<[React.FocusEvent<HTMLButtonElement>]>(
|
||||
field.onBlur,
|
||||
props.onBlur,
|
||||
)}
|
||||
checked={uncheckWhenDisabled && props.disabled ? false : field.value}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import type { FieldValues, UseControllerProps } from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledSelectProps<TFieldValues extends FieldValues = any>
|
||||
extends SelectProps<TFieldValues> {
|
||||
@@ -24,12 +24,13 @@ export interface ControlledSelectProps<TFieldValues extends FieldValues = any>
|
||||
|
||||
function ControlledSelect(
|
||||
{ controllerProps, name, control, ...props }: ControlledSelectProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
const { setValue } = useFormContext();
|
||||
const nameAttr = controllerProps?.name || name || '';
|
||||
const { field } = useController({
|
||||
...controllerProps,
|
||||
name: controllerProps?.name || name || '',
|
||||
name: nameAttr,
|
||||
control: controllerProps?.control || control,
|
||||
});
|
||||
|
||||
@@ -39,7 +40,7 @@ function ControlledSelect(
|
||||
{...field}
|
||||
ref={mergeRefs([field.ref, ref])}
|
||||
onChange={(event, value) => {
|
||||
setValue(controllerProps?.name || name, value, { shouldDirty: true });
|
||||
setValue(nameAttr, value, { shouldDirty: true });
|
||||
|
||||
if (props.onChange) {
|
||||
props.onChange(event, value);
|
||||
|
||||
@@ -2,13 +2,13 @@ import type { SwitchProps } from '@/components/ui/v2/Switch';
|
||||
import { Switch } from '@/components/ui/v2/Switch';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import type {
|
||||
ControllerProps,
|
||||
FieldValues,
|
||||
UseControllerProps,
|
||||
} from 'react-hook-form/dist/types';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
} from 'react-hook-form';
|
||||
import { useController, useFormContext } from 'react-hook-form';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface ControlledSwitchProps<TFieldValues extends FieldValues = any>
|
||||
extends SwitchProps {
|
||||
@@ -31,9 +31,10 @@ function ControlledSwitch(
|
||||
ref: ForwardedRef<HTMLSpanElement>,
|
||||
) {
|
||||
const { setValue } = useFormContext();
|
||||
const nameAttr = controllerProps?.name || name || '';
|
||||
const { field } = useController({
|
||||
...controllerProps,
|
||||
name: controllerProps?.name || name || '',
|
||||
name: nameAttr,
|
||||
control: controllerProps?.control || control,
|
||||
});
|
||||
|
||||
@@ -43,7 +44,7 @@ function ControlledSwitch(
|
||||
{...field}
|
||||
ref={mergeRefs([field.ref, ref])}
|
||||
onChange={(event) => {
|
||||
setValue(controllerProps?.name || name, event.target.checked, {
|
||||
setValue(nameAttr, event.target.checked, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ export interface FormProps extends BoxProps {
|
||||
/**
|
||||
* Function to be called when the form is submitted.
|
||||
*/
|
||||
onSubmit?: (...args: any[]) => any;
|
||||
onSubmit: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
const formRef = useRef<HTMLDivElement>();
|
||||
const formRef = useRef<HTMLDivElement | null>(null);
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
@@ -28,7 +28,7 @@ export default function Form({ onSubmit, onKeyDown, ...props }: FormProps) {
|
||||
}
|
||||
|
||||
const submitButton = Array.from(
|
||||
formRef.current.getElementsByTagName('button'),
|
||||
formRef.current!.getElementsByTagName('button'),
|
||||
).find((item) => item.type === 'submit');
|
||||
|
||||
// Disabling submit if the submit button is disabled
|
||||
|
||||
59
dashboard/src/components/form/FormInput/FormInput.tsx
Normal file
59
dashboard/src/components/form/FormInput/FormInput.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import { Input } from '@/components/ui/v3/input';
|
||||
import type { Control, FieldPath, FieldValues } from 'react-hook-form';
|
||||
|
||||
const inputClasses =
|
||||
'!bg-transparent aria-[invalid=true]:border-red-500 aria-[invalid=true]:focus:border-red-500 aria-[invalid=true]:focus:ring-red-500';
|
||||
|
||||
interface FormInputProps<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> {
|
||||
control: Control<TFieldValues>;
|
||||
name: TName;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
function FormInput<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
control,
|
||||
name,
|
||||
label,
|
||||
placeholder,
|
||||
className = '',
|
||||
type = 'text',
|
||||
}: FormInputProps<TFieldValues, TName>) {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type={type}
|
||||
placeholder={placeholder || label}
|
||||
{...field}
|
||||
className={`${inputClasses} ${className}`}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormInput;
|
||||
1
dashboard/src/components/form/FormInput/index.ts
Normal file
1
dashboard/src/components/form/FormInput/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as FormInput } from './FormInput';
|
||||
@@ -6,19 +6,24 @@ import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown, useDropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useSignOut, useUserData } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
function AccountMenuContent() {
|
||||
const user = useUserData();
|
||||
const { signOut } = useSignOut();
|
||||
const router = useRouter();
|
||||
const { signout } = useAuth();
|
||||
const apolloClient = useApolloClient();
|
||||
const { handleClose } = useDropdown();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
async function handleSignOut() {
|
||||
handleClose();
|
||||
await apolloClient.clearStore();
|
||||
await signout();
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className="grid grid-flow-row">
|
||||
<Box className="grid grid-flow-col items-center justify-start gap-3 p-4">
|
||||
@@ -70,12 +75,7 @@ function AccountMenuContent() {
|
||||
color="error"
|
||||
variant="borderless"
|
||||
className="w-full justify-start"
|
||||
onClick={async () => {
|
||||
handleClose();
|
||||
await apolloClient.clearStore();
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
}}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
|
||||
@@ -2,48 +2,50 @@ import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
|
||||
import { BaseLayout } from '@/components/layout/BaseLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { MainNav } from '@/components/layout/MainNav';
|
||||
import { useTreeNavState } from '@/components/layout/MainNav/TreeNavStateContext';
|
||||
import { HighlightedText } from '@/components/presentational/HighlightedText';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
|
||||
import Analytics from '@/components/analytics/analytics';
|
||||
import { useMediaQuery } from '@/components/common/useMediaQuery';
|
||||
import { MainNav } from '@/components/layout/MainNav';
|
||||
import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
||||
import { useTreeNavState } from '@/components/layout/MainNav/TreeNavStateContext';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { OrgStatus } from '@/features/orgs/components/OrgStatus';
|
||||
import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy';
|
||||
import { useNotFoundRedirect } from '@/features/orgs/projects/common/hooks/useNotFoundRedirect';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface AuthenticatedLayoutProps extends BaseLayoutProps {}
|
||||
export interface AuthenticatedLayoutProps extends BaseLayoutProps {
|
||||
withMainNav?: boolean;
|
||||
}
|
||||
|
||||
export default function AuthenticatedLayout({
|
||||
children,
|
||||
withMainNav = true,
|
||||
...props
|
||||
}: AuthenticatedLayoutProps) {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const isMdOrLarger = useMediaQuery('md');
|
||||
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const isHealthy = useIsHealthy();
|
||||
const [mainNavContainer, setMainNavContainer] = useState(null);
|
||||
const { isAuthenticated, isLoading, isSigningOut } = useAuth();
|
||||
const { isHealthy, isLoading: isHealthyLoading } = useIsHealthy();
|
||||
const [mainNavContainer, setMainNavContainer] = useState<HTMLElement | null>(
|
||||
null,
|
||||
);
|
||||
const { mainNavPinned } = useTreeNavState();
|
||||
|
||||
useNotFoundRedirect();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlatform || isLoading || isAuthenticated) {
|
||||
return;
|
||||
}
|
||||
|
||||
router.push('/signin');
|
||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
||||
|
||||
@@ -62,15 +64,15 @@ export default function AuthenticatedLayout({
|
||||
router.push('/orgs/local/projects/local');
|
||||
}, [isPlatform, router]);
|
||||
|
||||
if (isPlatform && isLoading) {
|
||||
if ((isPlatform && isLoading) || isSigningOut) {
|
||||
return (
|
||||
<BaseLayout className="h-full" {...props}>
|
||||
<Header className="flex max-h-[59px] flex-auto" />
|
||||
<Header className="flex max-h-[59px] flex-auto py-1" />
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPlatform && !isHealthy) {
|
||||
if (!isPlatform && !isHealthy && !isHealthyLoading) {
|
||||
return (
|
||||
<BaseLayout className="h-full" {...props}>
|
||||
<Header className="flex max-h-[59px] flex-auto" />
|
||||
@@ -124,10 +126,9 @@ export default function AuthenticatedLayout({
|
||||
{mainNavPinned && isMdOrLarger && <PinnedMainNav />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex h-full w-full flex-row bg-accent',
|
||||
mainNavPinned && isMdOrLarger ? 'overflow-x-auto' : '',
|
||||
)}
|
||||
className={cn('relative flex h-full w-full flex-row bg-accent', {
|
||||
'overflow-x-auto': mainNavPinned && isMdOrLarger && withMainNav,
|
||||
})}
|
||||
>
|
||||
{(!mainNavPinned || !isMdOrLarger) && (
|
||||
<div className="flex h-full w-6 justify-center">
|
||||
@@ -142,6 +143,7 @@ export default function AuthenticatedLayout({
|
||||
>
|
||||
<div className="flex h-full w-full flex-col overflow-auto">
|
||||
<OrgStatus />
|
||||
<Analytics />
|
||||
{children}
|
||||
</div>
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -30,7 +30,7 @@ type Option = {
|
||||
export default function OrgsComboBox() {
|
||||
const { orgs } = useOrgs();
|
||||
const isPlatform = useIsPlatform();
|
||||
const [, setLastSlug] = useSSRLocalStorage('slug', null);
|
||||
const [, setLastSlug] = useSSRLocalStorage<string | null>('slug', null);
|
||||
|
||||
const {
|
||||
query: { orgSlug },
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useSettingsDisabled } from '@/hooks/useSettingsDisabled';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||
@@ -55,6 +56,8 @@ export default function ProjectPagesComboBox() {
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const isSettingsDisabled = useSettingsDisabled();
|
||||
|
||||
const projectPages = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -68,7 +71,7 @@ export default function ProjectPagesComboBox() {
|
||||
label: 'Database',
|
||||
value: 'database',
|
||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||
slug: '/database/browser/default',
|
||||
slug: 'database/browser/default',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
@@ -146,10 +149,10 @@ export default function ProjectPagesComboBox() {
|
||||
value: 'settings',
|
||||
icon: <CogIcon className="h-4 w-4" />,
|
||||
slug: 'settings',
|
||||
disabled: false,
|
||||
disabled: isSettingsDisabled,
|
||||
},
|
||||
],
|
||||
[isPlatform],
|
||||
[isPlatform, isSettingsDisabled],
|
||||
);
|
||||
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
|
||||
@@ -14,7 +14,7 @@ import NavTree from './NavTree';
|
||||
import { useTreeNavState } from './TreeNavStateContext';
|
||||
|
||||
interface MainNavProps {
|
||||
container: HTMLElement;
|
||||
container: HTMLElement | null;
|
||||
}
|
||||
|
||||
export default function MainNav({ container }: MainNavProps) {
|
||||
@@ -41,7 +41,7 @@ export default function MainNav({ container }: MainNavProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<div
|
||||
className="min- absolute left-0 z-50 flex h-full w-6 justify-center border-r-[1px] bg-background pt-1 hover:bg-accent"
|
||||
className="min- absolute left-0 z-[39] flex h-full w-6 justify-center border-r-[1px] bg-background pt-1 hover:bg-accent"
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
>
|
||||
<Menu className="h-4 w-4" />
|
||||
|
||||
@@ -12,12 +12,12 @@ import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isNotEmptyValue } from '@/lib/utils';
|
||||
import { getConfigServerUrl, isPlatform as getIsPlatform } from '@/utils/env';
|
||||
import { Box, ChevronDown, ChevronRight, Plus } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useMemo, type ReactElement } from 'react';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
ControlledTreeEnvironment,
|
||||
@@ -160,7 +160,12 @@ const projectSettingsPages = [
|
||||
{ name: 'Configuration Editor', slug: 'editor', route: 'editor' },
|
||||
];
|
||||
|
||||
const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
const createOrganization = (org: Org) => {
|
||||
const isNotPlatform = !getIsPlatform();
|
||||
const configServerVariableNotSet = getConfigServerUrl() === '';
|
||||
const shouldDisableSettings = isNotPlatform && configServerVariableNotSet;
|
||||
const shouldDisableGraphite = shouldDisableSettings;
|
||||
|
||||
const result = {};
|
||||
|
||||
result[org.slug] = {
|
||||
@@ -211,7 +216,7 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
slug: 'new',
|
||||
icon: <Plus className="mr-1 h-4 w-4 font-bold" strokeWidth={3} />,
|
||||
targetUrl: `/orgs/${org.slug}/projects/new`,
|
||||
disabled: !isPlatform,
|
||||
disabled: isNotPlatform,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -238,9 +243,9 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
result[`${org.slug}-${_app.subdomain}-${_page.slug}`] = {
|
||||
index: `${org.slug}-${_app.subdomain}-${_page.slug}`,
|
||||
canMove: false,
|
||||
isFolder: _page.name === 'Settings',
|
||||
isFolder: _page.name === 'Settings' && !shouldDisableSettings,
|
||||
children:
|
||||
_page.name === 'Settings'
|
||||
_page.name === 'Settings' && !shouldDisableSettings
|
||||
? projectSettingsPages.map(
|
||||
(p) => `${org.slug}-${_app.subdomain}-settings-${p.slug}`,
|
||||
)
|
||||
@@ -251,9 +256,12 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
isProjectPage: true,
|
||||
targetUrl: `/orgs/${org.slug}/projects/${_app.subdomain}/${_page.route}`,
|
||||
disabled:
|
||||
['deployments', 'backups', 'logs', 'metrics'].includes(
|
||||
(['deployments', 'backups', 'logs', 'metrics'].includes(
|
||||
_page.slug,
|
||||
) && !isPlatform,
|
||||
) &&
|
||||
isNotPlatform) ||
|
||||
(_page.name === 'Settings' && shouldDisableSettings) ||
|
||||
(_page.name === 'AI' && shouldDisableGraphite),
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
@@ -272,6 +280,7 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
p.slug === 'general'
|
||||
? `/orgs/${org.slug}/projects/${_app.subdomain}/settings`
|
||||
: `/orgs/${org.slug}/projects/${_app.subdomain}/settings/${p.route}`,
|
||||
disabled: shouldDisableSettings,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
@@ -286,7 +295,7 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
data: {
|
||||
name: 'Settings',
|
||||
targetUrl: `/orgs/${org.slug}/settings`,
|
||||
disabled: !isPlatform,
|
||||
disabled: isNotPlatform,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
@@ -299,7 +308,7 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
data: {
|
||||
name: 'Members',
|
||||
targetUrl: `/orgs/${org.slug}/members`,
|
||||
disabled: !isPlatform,
|
||||
disabled: isNotPlatform,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
@@ -312,7 +321,7 @@ const createOrganization = (org: Org, isPlatform: boolean) => {
|
||||
data: {
|
||||
name: 'Billing',
|
||||
targetUrl: `/orgs/${org.slug}/billing`,
|
||||
disabled: !isPlatform,
|
||||
disabled: isNotPlatform,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
@@ -332,8 +341,7 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const buildNavTreeData = (
|
||||
org: Org,
|
||||
isPlatform: boolean,
|
||||
org?: Org,
|
||||
): { items: Record<TreeItemIndex, TreeItem<NavItem>> } => {
|
||||
if (!org) {
|
||||
return {
|
||||
@@ -365,7 +373,7 @@ const buildNavTreeData = (
|
||||
data: { name: 'root' },
|
||||
canRename: false,
|
||||
},
|
||||
...createOrganization(org, isPlatform),
|
||||
...createOrganization(org),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -374,11 +382,9 @@ const buildNavTreeData = (
|
||||
|
||||
export default function NavTree() {
|
||||
const { currentOrg: org } = useOrgs();
|
||||
const isPlatform = useIsPlatform();
|
||||
const navTree = useMemo(
|
||||
() => buildNavTreeData(org, isPlatform),
|
||||
[org, isPlatform],
|
||||
);
|
||||
|
||||
const navTree = buildNavTreeData(org);
|
||||
|
||||
const { orgsTreeViewState, setOrgsTreeViewState, setOpen } =
|
||||
useTreeNavState();
|
||||
|
||||
@@ -421,7 +427,7 @@ export default function NavTree() {
|
||||
asChild
|
||||
onClick={() => {
|
||||
// do not focus an item if we already there
|
||||
// this will prevent the case where clikcing on the project name
|
||||
// this will prevent the case where clicking on the project name
|
||||
// would focus on the project name instead of the overview page
|
||||
if (
|
||||
navTree.items[item.index].data.targetUrl ===
|
||||
@@ -436,8 +442,10 @@ export default function NavTree() {
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-full flex-row justify-start gap-1 bg-background px-1 text-foreground hover:bg-accent dark:hover:bg-muted',
|
||||
context.isFocused &&
|
||||
'bg-[#ebf3ff] hover:bg-[#ebf3ff] dark:bg-muted',
|
||||
{
|
||||
'bg-[#ebf3ff] hover:bg-[#ebf3ff] dark:bg-muted':
|
||||
context.isFocused,
|
||||
},
|
||||
item.data.disabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
>
|
||||
@@ -510,13 +518,19 @@ export default function NavTree() {
|
||||
canSearch={false}
|
||||
onExpandItem={(item) => {
|
||||
setOrgsTreeViewState(
|
||||
({ expandedItems: prevExpandedItems, ...rest }) => ({
|
||||
...rest,
|
||||
// Add item index to expandedItems only if it's not already present
|
||||
expandedItems: prevExpandedItems.includes(item.index)
|
||||
? prevExpandedItems
|
||||
: [...prevExpandedItems, item.index],
|
||||
}),
|
||||
({ expandedItems: prevExpandedItems, ...rest }) => {
|
||||
const newExpandedItems = isNotEmptyValue(prevExpandedItems)
|
||||
? [...prevExpandedItems]
|
||||
: [];
|
||||
|
||||
return {
|
||||
...rest,
|
||||
// Add item index to expandedItems only if it's not already present
|
||||
expandedItems: newExpandedItems?.includes(item.index)
|
||||
? prevExpandedItems
|
||||
: [...newExpandedItems, item.index],
|
||||
};
|
||||
},
|
||||
);
|
||||
}}
|
||||
onCollapseItem={(item) => {
|
||||
@@ -524,7 +538,7 @@ export default function NavTree() {
|
||||
({ expandedItems: prevExpandedItems, ...rest }) => ({
|
||||
...rest,
|
||||
// Remove the item index from expandedItems
|
||||
expandedItems: prevExpandedItems.filter(
|
||||
expandedItems: (prevExpandedItems ?? []).filter(
|
||||
(index) => index !== item.index,
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function PinnedMainNav() {
|
||||
query: { orgSlug },
|
||||
} = useRouter();
|
||||
|
||||
const scrollContainerRef = useRef();
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { mainNavPinned, setMainNavPinned } = useTreeNavState();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,6 +33,8 @@ export default function PinnedMainNav() {
|
||||
observer.observe(scrollContainerRef.current, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
// run scrollToElement when the class changes because of focus
|
||||
attributeFilter: ['class'],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -31,25 +31,19 @@ interface TreeNavProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function useSyncedTreeViewState(
|
||||
useTreeStateFromURL: () => {
|
||||
expandedItems: string[];
|
||||
focusedItem: string | null;
|
||||
},
|
||||
) {
|
||||
const { expandedItems, focusedItem } = useTreeStateFromURL();
|
||||
function useSyncedTreeViewState() {
|
||||
const { expandedItems, focusedItem } = useNavTreeStateFromURL();
|
||||
|
||||
const [state, setState] = useState<IndividualTreeViewState<never>>({
|
||||
const [state, setState] = useState<IndividualTreeViewState>({
|
||||
expandedItems,
|
||||
focusedItem,
|
||||
selectedItems: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setState((prevState) => ({
|
||||
...prevState,
|
||||
expandedItems: [
|
||||
...new Set([...prevState.expandedItems, ...expandedItems]),
|
||||
...new Set([...(prevState.expandedItems ?? []), ...expandedItems]),
|
||||
],
|
||||
focusedItem,
|
||||
}));
|
||||
@@ -64,7 +58,7 @@ function TreeNavStateProvider({ children }: TreeNavProviderProps) {
|
||||
'pin-nav-tree',
|
||||
true,
|
||||
);
|
||||
const orgsTreeViewState = useSyncedTreeViewState(useNavTreeStateFromURL);
|
||||
const orgsTreeViewState = useSyncedTreeViewState();
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
|
||||
@@ -10,8 +10,8 @@ import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useSignOut } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
@@ -22,11 +22,18 @@ export interface MobileNavProps extends ButtonProps {}
|
||||
export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { signOut } = useSignOut();
|
||||
const { signout } = useAuth();
|
||||
const apolloClient = useApolloClient();
|
||||
const router = useRouter();
|
||||
const { publicRuntimeConfig } = getConfig();
|
||||
|
||||
async function handleSignOut() {
|
||||
setMenuOpen(false);
|
||||
await apolloClient.clearStore();
|
||||
await signout();
|
||||
await router.push('/signin');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
@@ -120,12 +127,7 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
variant="borderless"
|
||||
sx={{ color: 'error.main' }}
|
||||
className="justify-start border-none px-2 py-2.5 text-[16px]"
|
||||
onClick={async () => {
|
||||
setMenuOpen(false);
|
||||
await apolloClient.clearStore();
|
||||
await signOut();
|
||||
await router.push('/signin');
|
||||
}}
|
||||
onClick={handleSignOut}
|
||||
>
|
||||
Sign Out
|
||||
</ListItem.Button>
|
||||
|
||||
@@ -146,7 +146,7 @@ export default function SettingsContainer({
|
||||
{!switchId && showSwitch && (
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(e) => onEnabledChange(e.target.checked)}
|
||||
onChange={(e) => onEnabledChange?.(e.target.checked)}
|
||||
className="self-center"
|
||||
{...switchSlot}
|
||||
/>
|
||||
|
||||
@@ -6,21 +6,24 @@ import { RetryableErrorBoundary } from '@/components/presentational/RetryableErr
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { ThemeProvider } from '@/components/ui/v2/ThemeProvider';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuth } from '@/providers/Auth';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export interface UnauthenticatedLayoutProps extends BaseLayoutProps {}
|
||||
export interface UnauthenticatedLayoutProps extends BaseLayoutProps {
|
||||
rightColumnContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function UnauthenticatedLayout({
|
||||
children,
|
||||
rightColumnContent,
|
||||
...props
|
||||
}: UnauthenticatedLayoutProps) {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const isOnResetPassword = router.route === '/password/reset';
|
||||
|
||||
useEffect(() => {
|
||||
@@ -63,38 +66,46 @@ export default function UnauthenticatedLayout({
|
||||
>
|
||||
<Container
|
||||
rootClassName="bg-transparent h-full"
|
||||
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-0"
|
||||
className="grid h-full w-full items-center justify-items-center gap-12 bg-transparent pb-12 pt-8 lg:grid-cols-2 lg:gap-4 lg:pb-0 lg:pt-8"
|
||||
>
|
||||
<div className="relative z-10 order-2 grid w-full max-w-[544px] grid-flow-row gap-12 lg:order-1">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<div className="relative z-0 order-1 flex h-full w-full items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none]">
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
|
||||
<div className="relative z-0 order-1 flex h-full w-full flex-col items-center justify-center md:min-h-[150px] lg:order-2 lg:min-h-[none] lg:gap-8">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="absolute bottom-0 left-0 right-0 top-0 mx-auto flex h-full w-full max-w-xl items-center justify-center overflow-hidden opacity-70">
|
||||
<Image
|
||||
priority
|
||||
src="/assets/line-grid.svg"
|
||||
width={1003}
|
||||
height={644}
|
||||
alt="Transparent lines"
|
||||
objectFit="fill"
|
||||
className="h-full w-full scale-[200%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
className="backface-hidden absolute left-0 right-0 z-0 mx-auto h-20 w-20 transform-gpu rounded-full opacity-80 blur-[56px]"
|
||||
sx={{
|
||||
backgroundColor: (theme) => theme.palette.primary.main,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Image
|
||||
priority
|
||||
src="/assets/line-grid.svg"
|
||||
width={1003}
|
||||
height={644}
|
||||
alt="Transparent lines"
|
||||
objectFit="fill"
|
||||
className="h-full w-full scale-[200%]"
|
||||
src="/assets/logo.svg"
|
||||
width={119}
|
||||
height={40}
|
||||
alt="Nhost Logo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Box
|
||||
className="backface-hidden absolute left-0 right-0 z-0 mx-auto h-20 w-20 transform-gpu rounded-full opacity-80 blur-[56px]"
|
||||
sx={{
|
||||
backgroundColor: (theme) => theme.palette.primary.main,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Image
|
||||
src="/assets/logo.svg"
|
||||
width={119}
|
||||
height={40}
|
||||
alt="Nhost Logo"
|
||||
/>
|
||||
{rightColumnContent && (
|
||||
<div className="relative z-10 w-full max-w-md px-4 lg:px-0">
|
||||
{rightColumnContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from '@/components/presentational/CopyToClipboardButton';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { CopyToClipboardButton as CopyToClipboardButtonOriginal } from './CopyToClipboardButton';
|
||||
import { getNodeText } from './getNodeText';
|
||||
|
||||
export interface CodeBlockPropsBase {
|
||||
@@ -19,7 +19,7 @@ export interface CodeBlockPropsBase {
|
||||
/**
|
||||
* Text of the toast that appears when the code is copied to the clipboard.
|
||||
*/
|
||||
copyToClipboardToastTitle?: string;
|
||||
copyToClipboardToastTitle: string;
|
||||
}
|
||||
|
||||
export type CodeBlockProps = CodeBlockPropsBase &
|
||||
@@ -63,7 +63,7 @@ interface CopyToClipboardButtonProps
|
||||
> {
|
||||
filenameColor?: string;
|
||||
tooltipColor?: string;
|
||||
toastTitle?: string;
|
||||
toastTitle: string;
|
||||
}
|
||||
|
||||
function CopyToClipboardButton({
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { Button, type ButtonProps } from '@/components/ui/v3/button';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { copy } from '@/utils/copy';
|
||||
import { clsx } from 'clsx';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
} from '@/components/ui/v2/IconButton';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { copy } from '@/utils/copy';
|
||||
|
||||
export function CopyToClipboardButton({
|
||||
function CopyToClipboardButton({
|
||||
textToCopy,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: {
|
||||
textToCopy: string;
|
||||
textToCopy?: string | null;
|
||||
title: string;
|
||||
} & IconButtonProps) {
|
||||
} & ButtonProps) {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -35,12 +32,18 @@ export function CopyToClipboardButton({
|
||||
if (!textToCopy || disabled) {
|
||||
return null;
|
||||
}
|
||||
const hasChildren = isNotEmptyValue(props.children);
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className={clsx('group', className)}
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className={clsx(
|
||||
'group h-fit w-fit border-0 bg-transparent p-[2px] hover:bg-[#d6eefb] dark:hover:bg-[#1e2942]',
|
||||
className,
|
||||
{ 'gap-3': hasChildren },
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
@@ -49,7 +52,9 @@ export function CopyToClipboardButton({
|
||||
aria-label={textToCopy}
|
||||
{...props}
|
||||
>
|
||||
<CopyIcon className="top-5 h-4 w-4" />
|
||||
</IconButton>
|
||||
{props.children}
|
||||
<Copy className="top-5 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
export default CopyToClipboardButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as CopyToClipboardButton } from './CopyToClipboardButton';
|
||||
@@ -1,13 +1,11 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
|
||||
export default function MaintenanceAlert() {
|
||||
const { maintenanceActive, maintenanceEndDate } = useUI();
|
||||
|
||||
if (!maintenanceActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function MaintenceEndDate({
|
||||
maintenanceEndDate,
|
||||
}: {
|
||||
maintenanceEndDate: Date;
|
||||
}) {
|
||||
const dateTimeFormat = Intl.DateTimeFormat(undefined, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
@@ -27,6 +25,21 @@ export default function MaintenanceAlert() {
|
||||
const minute = parts.find((part) => part.type === 'minute')?.value;
|
||||
const timeZone = parts.find((part) => part.type === 'timeZoneName')?.value;
|
||||
|
||||
return (
|
||||
<p>
|
||||
Maintenance is expected to be completed at {year}-{month}-{day} {hour}:
|
||||
{minute} {timeZone}.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MaintenanceAlert() {
|
||||
const { maintenanceActive, maintenanceEndDate } = useUI();
|
||||
|
||||
if (!maintenanceActive) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert severity="warning" className="mt-4">
|
||||
<p>
|
||||
@@ -36,10 +49,7 @@ export default function MaintenanceAlert() {
|
||||
</p>
|
||||
|
||||
{maintenanceEndDate && (
|
||||
<p>
|
||||
Maintenance is expected to be completed at {year}-{month}-{day} {hour}
|
||||
:{minute} {timeZone}.
|
||||
</p>
|
||||
<MaintenceEndDate maintenanceEndDate={maintenanceEndDate} />
|
||||
)}
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -5,8 +5,10 @@ import type { DetailedHTMLProps, ForwardedRef, HTMLProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ReadOnlyToggleProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLSpanElement>, HTMLSpanElement> {
|
||||
export type ReadOnlyToggleProps = Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLSpanElement>, HTMLSpanElement>,
|
||||
'checked'
|
||||
> & {
|
||||
/**
|
||||
* Determines whether the toggle is checked or not.
|
||||
*/
|
||||
@@ -24,7 +26,7 @@ export interface ReadOnlyToggleProps
|
||||
*/
|
||||
label?: TextProps;
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function ReadOnlyToggle(
|
||||
{ checked, className, slotProps = {}, ...props }: ReadOnlyToggleProps,
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type AvatarProps = Pick<BoxProps, 'component'> & {
|
||||
style?: {
|
||||
[key: string]: string;
|
||||
};
|
||||
className?: string;
|
||||
avatarUrl?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `v2/Avatar` instead.
|
||||
*/
|
||||
export default function Avatar({
|
||||
style = {},
|
||||
className = '',
|
||||
avatarUrl,
|
||||
name = '',
|
||||
...rest
|
||||
}: AvatarProps) {
|
||||
const noAvatar = !avatarUrl || avatarUrl.includes('blank');
|
||||
|
||||
const classes = twMerge(
|
||||
'border rounded-full bg-cover bg-center',
|
||||
className,
|
||||
noAvatar && 'border-0 text-white flex items-center justify-center',
|
||||
);
|
||||
|
||||
if (noAvatar) {
|
||||
const initials = name
|
||||
.split(' ')
|
||||
.slice(0, 2)
|
||||
.map((currentNamePart) => `${currentNamePart.charAt(0).toUpperCase()}`)
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classes}
|
||||
style={style}
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? `grey.400` : `grey.500`,
|
||||
color: (theme) => `${theme.palette.common.white} !important`,
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{initials}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={Object.assign(style, { backgroundImage: `url(${avatarUrl})` })}
|
||||
className={classes}
|
||||
aria-label={name ? `Avatar of ${name}` : 'Avatar'}
|
||||
role="img"
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Avatar';
|
||||
export { default as Avatar } from './Avatar';
|
||||
@@ -11,7 +11,7 @@ export default function ClientOnlyPortal({
|
||||
children,
|
||||
selector,
|
||||
}: ClientOnlyPortalProps) {
|
||||
const ref = useRef();
|
||||
const ref = useRef<Element | DocumentFragment>();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -19,5 +19,5 @@ export default function ClientOnlyPortal({
|
||||
setMounted(true);
|
||||
}, [selector]);
|
||||
|
||||
return mounted ? createPortal(children, ref.current) : null;
|
||||
return mounted ? createPortal(children, ref.current!) : null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import MaterialAutocomplete, {
|
||||
} from '@mui/material/Autocomplete';
|
||||
import clsx from 'clsx';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useRef, useState } from 'react';
|
||||
import { forwardRef, useEffect, useState } from 'react';
|
||||
|
||||
export interface AutocompleteOption<TValue = string> {
|
||||
/**
|
||||
@@ -51,7 +51,7 @@ export interface AutocompleteProps<
|
||||
TOption extends AutocompleteOption = AutocompleteOption,
|
||||
> extends Omit<
|
||||
MaterialAutocompleteProps<TOption, boolean, boolean, boolean>,
|
||||
'renderInput' | 'autoSelect' | 'componentsProps'
|
||||
'renderInput' | 'autoSelect' | 'componentsProps' | 'isOptionEqualToValue'
|
||||
>,
|
||||
Pick<
|
||||
InputProps,
|
||||
@@ -100,11 +100,20 @@ export interface AutocompleteProps<
|
||||
*
|
||||
* @default 'never'
|
||||
*/
|
||||
showCustomOption?: 'always' | 'never' | 'auto';
|
||||
showCustomOption?: 'always' | 'never' | 'auto' | 'first';
|
||||
/**
|
||||
* Custom option label.
|
||||
*/
|
||||
customOptionLabel?: string | ((customOptionLabel: string) => string);
|
||||
isOptionEqualToValue?: (
|
||||
option: AutocompleteOption<string>,
|
||||
value: string | AutocompleteOption<string>,
|
||||
) => boolean;
|
||||
|
||||
sortByOptions?: (
|
||||
option1: AutocompleteOption<string>,
|
||||
option2: AutocompleteOption<string>,
|
||||
) => number;
|
||||
}
|
||||
|
||||
const StyledTag = styled(Chip)(({ theme }) => ({
|
||||
@@ -213,11 +222,11 @@ function Autocomplete(
|
||||
customOptionLabel: externalCustomOptionLabel,
|
||||
showCustomOption = 'never',
|
||||
'aria-label': ariaLabel,
|
||||
sortByOptions,
|
||||
...props
|
||||
}: AutocompleteProps<AutocompleteOption>,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
) {
|
||||
const inputRef = useRef<HTMLInputElement>();
|
||||
const { formControl: formControlSlotProps, ...defaultComponentsProps } =
|
||||
slotProps || {};
|
||||
|
||||
@@ -228,7 +237,7 @@ function Autocomplete(
|
||||
// TODO: Revisit this implementation. We should probably have a better way to
|
||||
// make this component controlled.
|
||||
useEffect(() => {
|
||||
setInputValue(externalInputValue);
|
||||
setInputValue(externalInputValue ?? '');
|
||||
}, [externalInputValue]);
|
||||
|
||||
const filterOptionsFn = externalFilterOptions || filterOptions;
|
||||
@@ -264,6 +273,7 @@ function Autocomplete(
|
||||
openOnFocus
|
||||
disablePortal
|
||||
disableClearable
|
||||
autoFocus={false}
|
||||
componentsProps={{
|
||||
...defaultComponentsProps,
|
||||
popper: {
|
||||
@@ -316,7 +326,7 @@ function Autocomplete(
|
||||
}}
|
||||
isOptionEqualToValue={(
|
||||
option,
|
||||
value: string | number | AutocompleteOption<string>,
|
||||
value: string | AutocompleteOption<string>,
|
||||
) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
@@ -392,7 +402,24 @@ function Autocomplete(
|
||||
if (!inputValue) {
|
||||
return filteredOptions;
|
||||
}
|
||||
if (showCustomOption === 'first') {
|
||||
const isInputValueInOptions = filteredOptions.some(
|
||||
(filteredOption) => filteredOption.label === inputValue,
|
||||
);
|
||||
|
||||
return isInputValueInOptions
|
||||
? filteredOptions
|
||||
: [
|
||||
{
|
||||
value: inputValue,
|
||||
label: inputValue,
|
||||
dropdownLabel:
|
||||
customOptionLabel || `Select "${inputValue}"`,
|
||||
custom: Boolean(inputValue),
|
||||
},
|
||||
...filteredOptions,
|
||||
];
|
||||
}
|
||||
if (showCustomOption === 'auto') {
|
||||
const isInputValueInOptions = filteredOptions.some(
|
||||
(filteredOption) => filteredOption.label === inputValue,
|
||||
@@ -431,7 +458,6 @@ function Autocomplete(
|
||||
...params
|
||||
}) => (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
slotProps={{
|
||||
input: {
|
||||
className: slotProps?.input?.className,
|
||||
|
||||
@@ -92,7 +92,7 @@ function Checkbox(
|
||||
'aria-label': ariaLabel,
|
||||
...props
|
||||
}: CheckboxProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
if (!label) {
|
||||
return (
|
||||
|
||||
@@ -31,7 +31,9 @@ function ColorPreferenceProvider({
|
||||
colorPreferenceStorageKey,
|
||||
);
|
||||
|
||||
if (!['light', 'dark', 'system'].includes(storedColorPreference)) {
|
||||
if (
|
||||
!['light', 'dark', 'system'].includes(storedColorPreference as string)
|
||||
) {
|
||||
setColorPreference('system');
|
||||
|
||||
return;
|
||||
|
||||
@@ -9,16 +9,15 @@ import { pickersDayClasses } from '@mui/x-date-pickers/PickersDay';
|
||||
import type { StaticDatePickerProps } from '@mui/x-date-pickers/StaticDatePicker';
|
||||
import { StaticDatePicker } from '@mui/x-date-pickers/StaticDatePicker';
|
||||
|
||||
export interface DatePickerProps
|
||||
extends Omit<
|
||||
StaticDatePickerProps<any, Date>,
|
||||
'renderInput' | 'componentsProps' | 'renderDay'
|
||||
> {
|
||||
export type DatePickerProps = Omit<
|
||||
StaticDatePickerProps<any, Date>,
|
||||
'renderInput' | 'componentsProps' | 'renderDay' | 'onChange' | 'value'
|
||||
> & {
|
||||
/**
|
||||
* Date value to be displayed in the datepicker.
|
||||
* It should be of "yyyy-MM-dd'T'HH:mm" format.
|
||||
*/
|
||||
value: Date;
|
||||
value: Date | null;
|
||||
/**
|
||||
* If true, it will not allow the user to select any date.
|
||||
*/
|
||||
@@ -35,7 +34,7 @@ export interface DatePickerProps
|
||||
* Function to be called when the user selects a date.
|
||||
*/
|
||||
onChange: (value: Date, keyboardInputValue?: string) => void;
|
||||
}
|
||||
};
|
||||
|
||||
const CustomStaticDatePicker = styled(StaticDatePicker)(({ theme }) => ({
|
||||
borderRadius: '10px',
|
||||
@@ -177,7 +176,12 @@ function DatePicker({
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
onChange={(newValue) => onChange(newValue as Date)}
|
||||
renderInput={() => null}
|
||||
renderInput={
|
||||
(() => null) as unknown as StaticDatePickerProps<
|
||||
any,
|
||||
Date
|
||||
>['renderInput']
|
||||
}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
|
||||
@@ -3,10 +3,10 @@ import { ChevronUpIcon } from '@/components/ui/v2/icons/ChevronUpIcon';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUserData } from '@/hooks/useUserData';
|
||||
import { getToastBackgroundColor } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { InputBaseProps as MaterialInputBaseProps } from '@mui/material/Inp
|
||||
import MaterialInputBase, { inputBaseClasses } from '@mui/material/InputBase';
|
||||
import type { DetailedHTMLProps, ForwardedRef, HTMLProps } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import { mergeRefs } from 'react-merge-refs';
|
||||
|
||||
export interface InputProps
|
||||
extends Omit<MaterialInputBaseProps, 'componentsProps' | 'slotProps'>,
|
||||
|
||||
@@ -15,7 +15,9 @@ const LinearProgress = styled(MaterialLinearProgress)(({ theme, value }) => ({
|
||||
},
|
||||
[`& .${linearProgressClasses.bar}`]: {
|
||||
backgroundColor:
|
||||
value >= 100 ? theme.palette.error.dark : theme.palette.primary.main,
|
||||
value && value >= 100
|
||||
? theme.palette.error.dark
|
||||
: theme.palette.primary.main,
|
||||
},
|
||||
}));
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const StyledOption = styled(BaseOption)(({ theme }) => ({
|
||||
},
|
||||
}));
|
||||
|
||||
function Option<TValue>(
|
||||
function Option<TValue extends {}>(
|
||||
{ children, ...props }: OptionProps<TValue>,
|
||||
ref: ForwardedRef<HTMLLIElement>,
|
||||
) {
|
||||
|
||||
@@ -57,7 +57,7 @@ const StyledRadio = styled(MaterialRadio)(({ theme }) => ({
|
||||
|
||||
function Radio(
|
||||
{ label, value, slotProps, ...props }: RadioProps,
|
||||
ref: ForwardedRef<HTMLInputElement>,
|
||||
ref: ForwardedRef<HTMLButtonElement>,
|
||||
) {
|
||||
return (
|
||||
<StyledFormControlLabel
|
||||
|
||||
@@ -63,7 +63,7 @@ const StyledPopper = styled(BasePopper)`
|
||||
z-index: 9999;
|
||||
`;
|
||||
|
||||
function Select<TValue>(
|
||||
function Select<TValue extends {}>(
|
||||
{
|
||||
className,
|
||||
slotProps,
|
||||
|
||||
@@ -60,7 +60,7 @@ function Slider(
|
||||
ref={ref}
|
||||
components={{
|
||||
Rail: SliderRail({
|
||||
value: allowed,
|
||||
value: allowed ?? 0,
|
||||
max: props.max,
|
||||
marks: props.marks,
|
||||
step: props.step,
|
||||
@@ -69,7 +69,7 @@ function Slider(
|
||||
}}
|
||||
color="primary"
|
||||
{...props}
|
||||
marks={allowed > 0 ? false : props.marks}
|
||||
marks={allowed && allowed > 0 ? false : props.marks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,9 +29,6 @@ function ThemeProviderContent({
|
||||
'html, body': {
|
||||
backgroundColor: `${theme.palette.background.default} !important`,
|
||||
},
|
||||
html: {
|
||||
class: `${preferredColor}`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Head>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { forwardRef, type ForwardedRef } from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -27,10 +27,19 @@ export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
const Badge = forwardRef(
|
||||
(
|
||||
{ className, variant, ...props }: BadgeProps,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -30,36 +30,44 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
disableOutsideClick?: boolean;
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
DialogContentProps
|
||||
>(({ className, children, disableOutsideClick, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 grid w-full max-w-lg gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full',
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={
|
||||
disableOutsideClick
|
||||
? (e) => e.preventDefault()
|
||||
: props.onInteractOutside
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ className, children, disableOutsideClick, hideCloseButton, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay>
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 grid w-full max-w-lg gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full',
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={
|
||||
disableOutsideClick
|
||||
? (e) => e.preventDefault()
|
||||
: props.onInteractOutside
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogOverlay>
|
||||
</DialogPortal>
|
||||
),
|
||||
);
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({
|
||||
|
||||
@@ -13,16 +13,17 @@ import { cn } from '@/lib/utils';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
|
||||
type Option = Record<'value' | 'label', string>;
|
||||
export type Option = Record<'value' | 'label', string>;
|
||||
|
||||
interface FancyMultiSelectProps {
|
||||
defaultValue?: Option[];
|
||||
value?: Option[];
|
||||
options?: Option[];
|
||||
creatable?: boolean;
|
||||
className?: string;
|
||||
@@ -30,7 +31,7 @@ interface FancyMultiSelectProps {
|
||||
}
|
||||
|
||||
export function FancyMultiSelect({
|
||||
defaultValue = [],
|
||||
value = [],
|
||||
options = [],
|
||||
creatable = false,
|
||||
className,
|
||||
@@ -38,9 +39,13 @@ export function FancyMultiSelect({
|
||||
}: FancyMultiSelectProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<Option[]>(defaultValue);
|
||||
const [selected, setSelected] = useState<Option[]>(value);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(value);
|
||||
}, [value]);
|
||||
|
||||
const handleUnselect = useCallback((option: Option) => {
|
||||
setSelected((prev) => prev.filter((s) => s.value !== option.value));
|
||||
}, []);
|
||||
@@ -64,17 +69,12 @@ export function FancyMultiSelect({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(option: Option) => {
|
||||
setInputValue('');
|
||||
setSelected((prev) => {
|
||||
const newSelected = [...prev, option];
|
||||
onChange?.(newSelected);
|
||||
return newSelected;
|
||||
});
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
function handleSelect(option: Option) {
|
||||
setInputValue('');
|
||||
const newSelected = [...selected, option];
|
||||
setSelected(newSelected);
|
||||
onChange?.(newSelected);
|
||||
}
|
||||
|
||||
const selectables = useMemo(() => {
|
||||
const filtered = options.filter(
|
||||
@@ -87,7 +87,7 @@ export function FancyMultiSelect({
|
||||
return [
|
||||
...filtered,
|
||||
{
|
||||
value: inputValue.toLowerCase(),
|
||||
value: inputValue,
|
||||
label: inputValue,
|
||||
},
|
||||
];
|
||||
@@ -115,7 +115,10 @@ export function FancyMultiSelect({
|
||||
key={option.value}
|
||||
variant="outline"
|
||||
>
|
||||
<span className="overflow-x-hidden text-ellipsis whitespace-nowrap break-words font-medium">
|
||||
<span
|
||||
className="overflow-x-hidden text-ellipsis whitespace-nowrap break-words font-medium"
|
||||
data-testid={`badge-${option.label}`}
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
<button
|
||||
|
||||
@@ -20,7 +20,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-accent',
|
||||
prefix && 'pl-6',
|
||||
{ 'pl-6': prefix },
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
363
dashboard/src/components/ui/v3/multi-select.tsx
Normal file
363
dashboard/src/components/ui/v3/multi-select.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { cn, isNotEmptyValue } from '@/lib/utils';
|
||||
import { CheckIcon, ChevronsUpDownIcon, XIcon } from 'lucide-react';
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentPropsWithoutRef,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
type MultiSelectContextType = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
selectedValues: Set<string>;
|
||||
toggleValue: (value: string) => void;
|
||||
items: Map<string, ReactNode>;
|
||||
onItemAdded: (value: string, label: ReactNode) => void;
|
||||
};
|
||||
const MultiSelectContext = createContext<MultiSelectContextType | null>(null);
|
||||
|
||||
function useMultiSelectContext() {
|
||||
const context = useContext(MultiSelectContext);
|
||||
if (context == null) {
|
||||
throw new Error(
|
||||
'useMultiSelectContext must be used within a MultiSelectContext',
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function MultiSelect({
|
||||
children,
|
||||
values,
|
||||
defaultValues,
|
||||
onValuesChange,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
values?: string[];
|
||||
defaultValues?: string[];
|
||||
onValuesChange?: (values: string[]) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedValues, setSelectedValues] = useState(
|
||||
new Set<string>(values ?? defaultValues),
|
||||
);
|
||||
const [items, setItems] = useState<Map<string, ReactNode>>(new Map());
|
||||
|
||||
const toggleValue = useCallback(
|
||||
(value: string) => {
|
||||
const getNewSet = (prev: Set<string>) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(value)) {
|
||||
newSet.delete(value);
|
||||
} else {
|
||||
newSet.add(value);
|
||||
}
|
||||
return newSet;
|
||||
};
|
||||
setSelectedValues(getNewSet);
|
||||
onValuesChange?.([...getNewSet(selectedValues)]);
|
||||
},
|
||||
[onValuesChange, selectedValues],
|
||||
);
|
||||
|
||||
const onItemAdded = useCallback((value: string, label: ReactNode) => {
|
||||
setItems((prev) => {
|
||||
if (prev.get(value) === label) {
|
||||
return prev;
|
||||
}
|
||||
return new Map(prev).set(value, label);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
setOpen,
|
||||
selectedValues: values ? new Set(values) : selectedValues,
|
||||
toggleValue,
|
||||
items,
|
||||
onItemAdded,
|
||||
}),
|
||||
[open, values, items, toggleValue, onItemAdded, selectedValues],
|
||||
);
|
||||
|
||||
return (
|
||||
<MultiSelectContext.Provider value={value}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
{children}
|
||||
</Popover>
|
||||
</MultiSelectContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
} & ComponentPropsWithoutRef<typeof Button>) {
|
||||
const { open } = useMultiSelectContext();
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
{...props}
|
||||
variant={props.variant ?? 'outline'}
|
||||
role={props.role ?? 'combobox'}
|
||||
aria-expanded={props['aria-expanded'] ?? open}
|
||||
className={cn(
|
||||
"shadow-xs aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex h-auto min-h-9 w-fit items-center justify-between gap-2 overflow-hidden whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-1.5 text-sm outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectValue({
|
||||
placeholder,
|
||||
clickToRemove = true,
|
||||
className,
|
||||
placeHolderClassName,
|
||||
overflowBehavior = 'wrap-when-open',
|
||||
...props
|
||||
}: {
|
||||
placeholder?: string;
|
||||
clickToRemove?: boolean;
|
||||
overflowBehavior?: 'wrap' | 'wrap-when-open' | 'cutoff';
|
||||
placeHolderClassName?: string;
|
||||
} & Omit<ComponentPropsWithoutRef<'div'>, 'children'>) {
|
||||
const { selectedValues, toggleValue, items, open } = useMultiSelectContext();
|
||||
const [overflowAmount, setOverflowAmount] = useState(0);
|
||||
const valueRef = useRef<HTMLDivElement | null>(null);
|
||||
const overflowRef = useRef<HTMLDivElement | null>(null);
|
||||
const resizeObserverRef = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const shouldWrap =
|
||||
overflowBehavior === 'wrap' ||
|
||||
(overflowBehavior === 'wrap-when-open' && open);
|
||||
|
||||
const checkOverflow = useCallback(() => {
|
||||
if (valueRef.current == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerElement = valueRef.current;
|
||||
const overflowElement = overflowRef.current;
|
||||
const selectedItems = containerElement.querySelectorAll<HTMLElement>(
|
||||
'[data-selected-item]',
|
||||
);
|
||||
|
||||
if (overflowElement != null) {
|
||||
overflowElement.style.display = 'none';
|
||||
}
|
||||
selectedItems.forEach((child) => child.style.removeProperty('display'));
|
||||
let amount = 0;
|
||||
// eslint-disable-next-line no-plusplus
|
||||
for (let i = selectedItems.length - 1; i >= 0; i--) {
|
||||
const child = selectedItems[i];
|
||||
if (containerElement.scrollWidth <= containerElement.clientWidth) {
|
||||
break;
|
||||
}
|
||||
amount = selectedItems.length - i;
|
||||
child.style.display = 'none';
|
||||
overflowElement?.style.removeProperty('display');
|
||||
}
|
||||
setOverflowAmount(amount);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
checkOverflow();
|
||||
}, [selectedValues, checkOverflow, shouldWrap]);
|
||||
|
||||
const handleResize = useCallback(
|
||||
(node: HTMLDivElement) => {
|
||||
if (isNotEmptyValue(node)) {
|
||||
valueRef.current = node;
|
||||
|
||||
resizeObserverRef.current = new ResizeObserver(checkOverflow);
|
||||
resizeObserverRef.current.observe(node);
|
||||
} else {
|
||||
resizeObserverRef.current?.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
valueRef.current = null;
|
||||
}
|
||||
},
|
||||
[checkOverflow],
|
||||
);
|
||||
|
||||
if (selectedValues.size === 0 && placeholder) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'min-w-0 overflow-hidden font-normal text-muted-foreground',
|
||||
placeHolderClassName,
|
||||
)}
|
||||
>
|
||||
{placeholder}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
ref={handleResize}
|
||||
className={cn(
|
||||
'flex w-full gap-1.5 overflow-hidden',
|
||||
shouldWrap && 'h-full flex-wrap',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{[...selectedValues]
|
||||
.filter((value) => items.has(value))
|
||||
.map((value) => (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-selected-item
|
||||
className="group flex items-center gap-1"
|
||||
key={value}
|
||||
onClick={
|
||||
clickToRemove
|
||||
? (e) => {
|
||||
e.stopPropagation();
|
||||
toggleValue(value);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{items.get(value)}
|
||||
{clickToRemove && (
|
||||
<XIcon className="size-2 text-muted-foreground group-hover:text-destructive" />
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
<Badge
|
||||
style={{
|
||||
display: overflowAmount > 0 && !shouldWrap ? 'block' : 'none',
|
||||
}}
|
||||
variant="outline"
|
||||
ref={overflowRef}
|
||||
>
|
||||
+{overflowAmount}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectContent({
|
||||
search = true,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
search?: boolean | { placeholder?: string; emptyMessage?: string };
|
||||
children: ReactNode;
|
||||
} & Omit<ComponentPropsWithoutRef<typeof Command>, 'children'>) {
|
||||
const canSearch = typeof search === 'object' ? true : search;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'none' }}>
|
||||
<Command>
|
||||
<CommandList>{children}</CommandList>
|
||||
</Command>
|
||||
</div>
|
||||
<PopoverContent className="min-w-[var(--radix-popover-trigger-width)] p-0">
|
||||
<Command {...props}>
|
||||
{canSearch ? (
|
||||
<CommandInput
|
||||
placeholder={
|
||||
typeof search === 'object' ? search.placeholder : undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label, jsx-a11y/no-autofocus
|
||||
<button type="button" autoFocus className="sr-only" />
|
||||
)}
|
||||
<CommandList>
|
||||
{canSearch && (
|
||||
<CommandEmpty>
|
||||
{typeof search === 'object' ? search.emptyMessage : undefined}
|
||||
</CommandEmpty>
|
||||
)}
|
||||
{children}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectItem({
|
||||
value,
|
||||
children,
|
||||
badgeLabel,
|
||||
onSelect,
|
||||
...props
|
||||
}: {
|
||||
badgeLabel?: ReactNode;
|
||||
value: string;
|
||||
} & Omit<ComponentPropsWithoutRef<typeof CommandItem>, 'value'>) {
|
||||
const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext();
|
||||
const isSelected = selectedValues.has(value);
|
||||
|
||||
useEffect(() => {
|
||||
onItemAdded(value, badgeLabel ?? children);
|
||||
}, [value, children, onItemAdded, badgeLabel]);
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
{...props}
|
||||
value={value}
|
||||
onSelect={(v) => {
|
||||
toggleValue(v);
|
||||
onSelect?.(v);
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
className={cn('mr-2 size-4', isSelected ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{children}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
export function MultiSelectGroup(
|
||||
props: ComponentPropsWithoutRef<typeof CommandGroup>,
|
||||
) {
|
||||
return <CommandGroup {...props} />;
|
||||
}
|
||||
|
||||
export function MultiSelectSeparator(
|
||||
props: ComponentPropsWithoutRef<typeof CommandSeparator>,
|
||||
) {
|
||||
return <CommandSeparator {...props} />;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const PopoverContent = React.forwardRef<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
'z-50 w-72 origin-[--radix-popover-content-transform-origin] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -52,7 +52,7 @@ const sheetVariants = cva(
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {
|
||||
container?: HTMLElement;
|
||||
container?: HTMLElement | null;
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ const SheetContent = React.forwardRef<
|
||||
{
|
||||
side = 'right',
|
||||
className,
|
||||
container,
|
||||
container = null,
|
||||
hideCloseButton,
|
||||
children,
|
||||
...props
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import useMfaEnabled from '@/features/account/settings/components/AccountMfaSettings/hooks/useMfaEnabled';
|
||||
import DisableMfaButton from './DisableMfaButton/DisableMfaButton';
|
||||
import EnableMfaButton from './EnableMfaButton/EnableMfaButton';
|
||||
|
||||
function MFaEnabledBadge() {
|
||||
return (
|
||||
<Badge variant="outline" className="border-green-400 text-green-400">
|
||||
Enabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function MFaDisabledBadge() {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text- border-destructive text-destructive"
|
||||
>
|
||||
Disabled
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function AccountMfaSettings() {
|
||||
const { isMfaEnabled } = useMfaEnabled();
|
||||
return (
|
||||
<div className="rounded-lg border border-[#EAEDF0] bg-white font-['Inter_var'] dark:border-[#2F363D] dark:bg-paper">
|
||||
<div className="flex w-full flex-col items-start gap-6 p-4">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<h3 className="flex items-center text-[1.125rem] font-semibold leading-[1.75]">
|
||||
<span className="mr-4">Multi-Factor Authentication </span>
|
||||
{isMfaEnabled ? <MFaEnabledBadge /> : <MFaDisabledBadge />}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center border-t border-[#EAEDF0] px-4 py-2 dark:border-[#2F363D]">
|
||||
{isMfaEnabled ? <DisableMfaButton /> : <EnableMfaButton />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AccountMfaSettings;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user