Compare commits

..

6 Commits

Author SHA1 Message Date
David B.M.
f539713e10 feat: add set to null edit button 2025-03-16 23:46:07 +01:00
David B.M.
9b150c786e fix: correct types when there is no actual default value 2025-03-16 23:46:07 +01:00
David B.M.
3d917b6e12 chore: asd 2025-03-16 23:46:07 +01:00
David B.M.
7574504cd5 chore: add changeset 2025-03-16 23:46:07 +01:00
David B.M.
be9f6b6967 feat: create rows with default empty string value 2025-03-16 23:46:07 +01:00
David B.M.
affe9db42a feat: add empty string default in table creation 2025-03-16 23:46:07 +01:00
1759 changed files with 99647 additions and 21084 deletions

View File

@@ -0,0 +1,5 @@
---
'@nhost/dashboard': minor
---
feat: add empty string as default value for text in databases

View File

@@ -0,0 +1,5 @@
---
'@nhost/dashboard': minor
---
fix: update babel dependencies to address security audit vulnerabilities

19
.github/labeler.yml vendored
View File

@@ -1,25 +1,24 @@
dashboard: dashboard:
- any: - dashboard/**/*
- changed-files:
- any-glob-to-any-file: ['dashboard/**/*']
documentation: documentation:
- any: ['docs/**/*'] - any:
- docs/**/*
examples: examples:
- any: ['examples/**/*'] - examples/**/*
sdk: sdk:
- any: ['packages/**/*'] - packages/**/*
integrations: integrations:
- any: ['integrations/**/*'] - integrations/**/*
react: react:
- any: ['{packages,examples,integrations}/*react*/**/*'] - '{packages,examples,integrations}/*react*/**/*'
nextjs: nextjs:
- any: ['{packages,examples}/*next*/**/*'] - '{packages,examples}/*next*/**/*'
vue: vue:
- any: ['{packages,examples,integrations}/*vue*/**/*'] - '{packages,examples,integrations}/*vue*/**/*'

View File

@@ -7,20 +7,19 @@ on:
- 'assets/**' - 'assets/**'
- '**.md' - '**.md'
- 'LICENSE' - 'LICENSE'
- 'docs/**'
pull_request: pull_request:
types: [opened, synchronize] types: [opened, synchronize]
paths-ignore: paths-ignore:
- 'assets/**' - 'assets/**'
- '**.md' - '**.md'
- 'LICENSE' - 'LICENSE'
- 'docs/**'
env: env:
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
TURBO_TEAM: nhost TURBO_TEAM: nhost
NEXT_PUBLIC_ENV: dev NEXT_PUBLIC_ENV: dev
NEXT_TELEMETRY_DISABLED: 1 NEXT_TELEMETRY_DISABLED: 1
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }} NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }} NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
NHOST_TEST_ORGANIZATION_NAME: ${{ vars.NHOST_TEST_ORGANIZATION_NAME }} NHOST_TEST_ORGANIZATION_NAME: ${{ vars.NHOST_TEST_ORGANIZATION_NAME }}
NHOST_TEST_ORGANIZATION_SLUG: ${{ vars.NHOST_TEST_ORGANIZATION_SLUG }} NHOST_TEST_ORGANIZATION_SLUG: ${{ vars.NHOST_TEST_ORGANIZATION_SLUG }}
@@ -29,8 +28,7 @@ env:
NHOST_PRO_TEST_PROJECT_NAME: ${{ vars.NHOST_PRO_TEST_PROJECT_NAME }} NHOST_PRO_TEST_PROJECT_NAME: ${{ vars.NHOST_PRO_TEST_PROJECT_NAME }}
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }} NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }} 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: jobs:
build: build:
@@ -172,10 +170,6 @@ jobs:
- name: Set Dashboard Preview URL - name: Set Dashboard Preview URL
if: steps.fetch-dashboard-preview-url.outputs.preview_url != '' if: steps.fetch-dashboard-preview-url.outputs.preview_url != ''
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
- name: Run Upgrade project Dashboard e2e tests
if: matrix.package.path == 'dashboard'
timeout-minutes: 10
run: pnpm --filter="${{ matrix.package.name }}" run e2e:upgrade-project
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo # * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
- name: Run e2e tests - name: Run e2e tests
timeout-minutes: 20 timeout-minutes: 20
@@ -184,16 +178,13 @@ jobs:
- name: Run Local Dashboard e2e tests - name: Run Local Dashboard e2e tests
if: matrix.package.path == 'dashboard' if: matrix.package.path == 'dashboard'
timeout-minutes: 5 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 - name: Stop Nhost CLI
if: matrix.package.path == 'dashboard' if: matrix.package.path == 'dashboard'
working-directory: ./nhost-test-project working-directory: ./nhost-test-project
run: nhost down run: nhost down
- 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
- id: file-name - id: file-name
if: ${{ failure() }} if: ${{ failure() }}
name: Transform package name into a valid file name name: Transform package name into a valid file name

View File

@@ -56,31 +56,3 @@ jobs:
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }} vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
vercel build --prod --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 }} 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'

View File

@@ -3,12 +3,13 @@ on:
- pull_request_target - pull_request_target
jobs: jobs:
labeler: triage:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@v5 - uses: actions/labeler@v4
with: with:
repo-token: ${{ secrets.GH_PAT }} repo-token: '${{ secrets.GH_PAT }}'
sync-labels: ''

View File

@@ -31,7 +31,7 @@ PRs to our libraries are always welcome and can be a quick way to get your fix o
- Only fix/add the functionality in question **OR** address wide-spread whitespace/style issues, not both. - Only fix/add the functionality in question **OR** address wide-spread whitespace/style issues, not both.
- Add unit or integration tests for fixed or changed functionality (if a test suite exists). - Add unit or integration tests for fixed or changed functionality (if a test suite exists).
- Address a single concern in the least number of changed lines as possible. - Address a single concern in the least number of changed lines as possible.
- Include documentation in the repo or on our [docs site](https://docs.nhost.io). - Include documentation in the repo or on our [docs site](https://docs.nhost.io/get-started).
- Be accompanied by a complete Pull Request template (loaded automatically when a PR is created). - Be accompanied by a complete Pull Request template (loaded automatically when a PR is created).
For changes that address core functionality or require breaking changes (e.g., a major release), it's best to open an Issue to discuss your proposal first. This is not required but can save time creating and reviewing changes. For changes that address core functionality or require breaking changes (e.g., a major release), it's best to open an Issue to discuss your proposal first. This is not required but can save time creating and reviewing changes.

View File

@@ -2,7 +2,9 @@
## Requirements ## Requirements
### Node.js v20 or later ### Node.js v18
_⚠️ Node.js v16 is also supported for the time being but support will be dropped in the near future_.
### [pnpm](https://pnpm.io/) package manager ### [pnpm](https://pnpm.io/) package manager
@@ -12,10 +14,10 @@ The easiest way to install `pnpm` if it's not installed on your machine yet is t
$ npm install -g pnpm $ npm install -g pnpm
``` ```
### [Nhost CLI](https://docs.nhost.io/platform/cli/local-development) ### [Nhost CLI](https://docs.nhost.io/cli)
- The CLI is primarily used for running the E2E tests - The CLI is primarily used for running the E2E tests
- Please refer to the [installation guide](https://docs.nhost.io/platform/cli/local-development) if you have not installed it yet - Please refer to the [installation guide](https://docs.nhost.io/get-started/cli-workflow/install-cli) if you have not installed it yet
## File Structure ## File Structure

View File

@@ -4,7 +4,7 @@
# Nhost # Nhost
<a href="https://docs.nhost.io/getting-started/overview">Quickstart</a> <a href="https://docs.nhost.io/introduction#quick-start-guides">Quickstart</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span> <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
<a href="http://nhost.io/">Website</a> <a href="http://nhost.io/">Website</a>
<span>&nbsp;&nbsp;•&nbsp;&nbsp;</span> <span>&nbsp;&nbsp;•&nbsp;&nbsp;</span>
@@ -36,7 +36,7 @@ Nhost consists of open source software:
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/) - Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage) - Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
- Serverless Functions: Node.js (JavaScript and TypeScript) - Serverless Functions: Node.js (JavaScript and TypeScript)
- [Nhost CLI](https://docs.nhost.io/platform/cli/local-development) for local development - [Nhost CLI](https://docs.nhost.io/development/cli/overview) for local development
## Architecture of Nhost ## Architecture of Nhost
@@ -89,25 +89,25 @@ await nhost.graphql.request(`{
Nhost is frontend agnostic, which means Nhost works with all frontend frameworks. Nhost is frontend agnostic, which means Nhost works with all frontend frameworks.
<div align="center"> <div align="center">
<a href="https://docs.nhost.io/getting-started/quickstart/nextjs"><img src="assets/nextjs.svg"/></a> <a href="https://docs.nhost.io/guides/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
<a href="https://docs.nhost.io/reference/javascript/nhost-js/nhost-client"><img src="assets/nuxtjs.svg"/></a> <a href="https://docs.nhost.io/reference/javascript"><img src="assets/nuxtjs.svg"/></a>
<a href="https://docs.nhost.io/getting-started/quickstart/react"><img src="assets/react.svg"/></a> <a href="https://docs.nhost.io/guides/quickstarts/react"><img src="assets/react.svg"/></a>
<a href="https://docs.nhost.io/getting-started/quickstart/reactnative"><img src="assets/react-native.svg"/></a> <a href="https://docs.nhost.io/reference/javascript"><img src="assets/react-native.svg"/></a>
<a href="https://docs.nhost.io/reference/javascript/nhost-js/nhost-client"><img src="assets/svelte.svg"/></a> <a href="https://docs.nhost.io/reference/javascript"><img src="assets/svelte.svg"/></a>
<a href="https://docs.nhost.io/getting-started/quickstart/vue"><img src="assets/vuejs.svg"/></a> <a href="https://docs.nhost.io/guides/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
</div> </div>
# Resources # Resources
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/platform/cli/local-development) - Start developing locally with the [Nhost CLI](https://docs.nhost.io/cli)
## Nhost Clients ## Nhost Clients
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript/nhost-js/nhost-client) - [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript)
- [Dart and Flutter](https://github.com/nhost/nhost-dart) - [Dart and Flutter](https://github.com/nhost/nhost-dart)
- [React](https://docs.nhost.io/reference/react/nhost-client) - [React](https://docs.nhost.io/reference/react)
- [Next.js](https://docs.nhost.io/reference/nextjs/nhost-client) - [Next.js](https://docs.nhost.io/reference/nextjs)
- [Vue](https://docs.nhost.io/reference/vue/nhost-client) - [Vue](https://docs.nhost.io/reference/vue)
## Integrations ## Integrations
@@ -140,7 +140,7 @@ This repository, and most of our other open source projects, are licensed under
Here are some ways of contributing to making Nhost better: Here are some ways of contributing to making Nhost better:
- **[Try out Nhost](https://docs.nhost.io)**, and think of ways to make the service better. Let us know here on GitHub. - **[Try out Nhost](https://docs.nhost.io/introduction)**, and think of ways to make the service better. Let us know here on GitHub.
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from. - Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution! - Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!

View File

@@ -2,5 +2,5 @@
// $schema provides code completion hints to IDEs. // $schema provides code completion hints to IDEs.
"$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json", "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
"moderate": true, "moderate": true,
"allowlist": ["vue-template-compiler", { "id": "CVE-2025-48068", "path": "next" }] "allowlist": ["vue-template-compiler"]
} }

View File

@@ -11,9 +11,20 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "node", "moduleResolution": "node",
"target": "ESNext", "target": "ES6",
"module": "CommonJS", "module": "CommonJS",
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": [
"es5",
"dom",
"es2015.promise",
"es2015.symbol",
"es2015.iterable",
"es2015.collection",
"es2015.symbol.wellknown",
"es2015.core",
"es2017.object",
"es2017.string"
],
"resolveJsonModule": true, "resolveJsonModule": true,
"esModuleInterop": true, "esModuleInterop": true,
"sourceMap": true, "sourceMap": true,
@@ -68,4 +79,4 @@
"**/*/__tests__", "**/*/__tests__",
"**/*/__mocks__" "**/*/__mocks__"
] ]
} }

View File

@@ -25,6 +25,4 @@ NEXT_PUBLIC_ZENDESK_USER_EMAIL=
CODEGEN_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1 CODEGEN_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret 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=

View File

@@ -76,13 +76,6 @@ module.exports = {
], ],
}, },
], ],
'jsx-a11y/label-has-associated-control': [
2,
{
controlComponents: ['Input'],
depth: 3,
},
],
}, },
overrides: [ overrides: [
{ {

View File

@@ -1,9 +1,8 @@
import { NhostProvider } from '@/providers/nhost';
import '@fontsource/inter'; import '@fontsource/inter';
import '@fontsource/inter/500.css'; import '@fontsource/inter/500.css';
import '@fontsource/inter/700.css'; import '@fontsource/inter/700.css';
import { CssBaseline, ThemeProvider } from '@mui/material'; import { CssBaseline, ThemeProvider } from '@mui/material';
import { createClient } from '@nhost/nhost-js-beta'; import { NhostClient, NhostProvider } from '@nhost/nextjs';
import { NhostApolloProvider } from '@nhost/react-apollo'; import { NhostApolloProvider } from '@nhost/react-apollo';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
@@ -59,9 +58,7 @@ export const decorators = [
</NhostApolloProvider> </NhostApolloProvider>
), ),
(Story) => ( (Story) => (
<NhostProvider <NhostProvider nhost={new NhostClient({ subdomain: 'local' })}>
nhost={createClient({ subdomain: 'local', region: 'local' })}
>
<Story /> <Story />
</NhostProvider> </NhostProvider>
), ),

View File

@@ -1,116 +1,5 @@
# @nhost/dashboard # @nhost/dashboard
## 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
- 013e1c1: fix: update vite and image-size dependencies to address security audit vulnerabilities
- 4fd176b: chore: re-add user event ci tests, updated sveltekit example tests to e2e suite
### Patch Changes
- a1333df: fix: update vite because of vulnerability
- 0420e4f: fix (dashboard): Display the selected date's month in the datetime picker component
- @nhost/react-apollo@17.0.3
- @nhost/nextjs@2.2.6
## 2.26.0
### Minor Changes
- 7b9cdf1: chore: remove legacy workspaces
- 1c4f321: fix: update vite to fix audit vulnerabilities
## 2.25.0
### Minor Changes
- 34fdcb8: chore: add prettier plugins as devDependencies to root of monorepo
- 4937c5e: fix: stop content overflowing in projects and database permissions page
- 1542132: fix: update babel dependencies to address security audit vulnerabilities
### Patch Changes
- 78436ca: chore (dashboard): add tests and small updates to PiTR settings and restore page
- b5a3895: chore (dashboard): update page context after each navigation
- 9b24807: chore: fix link to PiTR documentation
- ea65846: chore (dashboard): update nextjs to fix middleware exploit
## 2.17.0 ## 2.17.0
### Minor Changes ### Minor Changes

View File

@@ -38,7 +38,7 @@ These files are added to `.gitignore`, so you don't need to worry about committi
### Enable Local Development ### Enable Local Development
You can connect the Nhost Dashboard to your **locally running** Nhost backend in a few steps. Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli/local-development). You can connect the Nhost Dashboard to your **locally running** Nhost backend in a few steps. Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli#installation).
First, you need to run the following command to start your backend locally: First, you need to run the following command to start your backend locally:
@@ -149,11 +149,8 @@ Next, you need to create a project. Create a `.env.test` file with the following
NHOST_TEST_DASHBOARD_URL=<test_dashboard_url> NHOST_TEST_DASHBOARD_URL=<test_dashboard_url>
NHOST_TEST_USER_EMAIL=<test_user_email> NHOST_TEST_USER_EMAIL=<test_user_email>
NHOST_TEST_USER_PASSWORD=<test_user_password> NHOST_TEST_USER_PASSWORD=<test_user_password>
NHOST_TEST_ORGANIZATION_NAME=<test_organization_name> NHOST_TEST_WORKSPACE_NAME=<test_workspace_name>
NHOST_TEST_ORGANIZATION_SLUG=<test_organization_slug>
NHOST_TEST_PERSONAL_ORG_SLUG=<test_personal_org_slug>
NHOST_TEST_PROJECT_NAME=<test_project_name> NHOST_TEST_PROJECT_NAME=<test_project_name>
NHOST_TEST_PROJECT_SUBDOMAIN=<test_project_subdomain>
NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret> NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
``` ```
@@ -162,14 +159,11 @@ NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
- `NHOST_TEST_DASHBOARD_URL`: The URL to run the tests against (e.g: http://localhost:3000 or https://staging.app.nhost.io) - `NHOST_TEST_DASHBOARD_URL`: The URL to run the tests against (e.g: http://localhost:3000 or https://staging.app.nhost.io)
- `NHOST_TEST_USER_EMAIL`: Email address of the test user that owns the test project - `NHOST_TEST_USER_EMAIL`: Email address of the test user that owns the test project
- `NHOST_TEST_USER_PASSWORD`: Password of the test user that owns the test project - `NHOST_TEST_USER_PASSWORD`: Password of the test user that owns the test project
- `NHOST_TEST_ORGANIZATION_NAME`: Name of the organization that contains the test project - `NHOST_TEST_WORKSPACE_NAME`: Name of the workspace that contains the test project
- `NHOST_TEST_ORGANIZATION_SLUG`: Slug of the organization that contains the test project
- `NHOST_TEST_PERSONAL_ORG_SLUG`: Slug of the personal organization that contains the test project
- `NHOST_TEST_PROJECT_NAME`: Name of the test project - `NHOST_TEST_PROJECT_NAME`: Name of the test project
- `NHOST_TEST_PROJECT_SUBDOMAIN`: Subdomain of the test project
- `NHOST_TEST_PROJECT_ADMIN_SECRET`: Admin secret of the test project - `NHOST_TEST_PROJECT_ADMIN_SECRET`: Admin secret of the test project
Make sure to copy the organization and project information from the [Nhost Dashboard](https://app.nhost.io/). Make sure to copy the workspace and project information from the [Nhost Dashboard](https://app.nhost.io/).
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command: End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:

View File

@@ -1,10 +1,22 @@
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
test('should be able to create then delete a personal access token', async ({ let page: Page;
authenticatedNhostPage: page,
}) => { test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
});
test.afterAll(async () => {
await page.close();
});
test('should be able to create then delete a personal access token', async () => {
await page.waitForTimeout(1000); await page.waitForTimeout(1000);
await page.getByRole('banner').getByRole('button').last().click(); await page.getByRole('banner').getByRole('button').last().click();
await page.getByRole('link', { name: /account settings/i }).click(); await page.getByRole('link', { name: /account settings/i }).click();

View File

@@ -1,8 +1,17 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { navigateToProject } from '@/e2e/utils'; import { navigateToProject } from '@/e2e/utils';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await navigateToProject({ await navigateToProject({
page, page,
orgSlug: TEST_ORGANIZATION_SLUG, orgSlug: TEST_ORGANIZATION_SLUG,
@@ -14,9 +23,11 @@ test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await page.waitForURL(AIRoute); await page.waitForURL(AIRoute);
}); });
test('should create and delete an Assistant', async ({ test.afterAll(async () => {
authenticatedNhostPage: page, await page.close();
}) => { });
test('should create and delete an Assistant', async () => {
await page.getByRole('link', { name: 'Assistants' }).click(); await page.getByRole('link', { name: 'Assistants' }).click();
await expect(page.getByText(/no assistants are configured/i)).toBeVisible(); await expect(page.getByText(/no assistants are configured/i)).toBeVisible();

View File

@@ -1,9 +1,17 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { navigateToProject } from '@/e2e/utils'; import { navigateToProject } from '@/e2e/utils';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { expect, test } from '@/e2e/fixtures/auth-hook'; let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await navigateToProject({ await navigateToProject({
page, page,
orgSlug: TEST_ORGANIZATION_SLUG, orgSlug: TEST_ORGANIZATION_SLUG,
@@ -15,9 +23,11 @@ test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await page.waitForURL(AIRoute); await page.waitForURL(AIRoute);
}); });
test('should create and delete an Auto-Embeddings', async ({ test.afterAll(async () => {
authenticatedNhostPage: page, await page.close();
}) => { });
test('should create and delete an Auto-Embeddings', async () => {
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click(); await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
await page.getByLabel('Name').fill('test'); await page.getByLabel('Name').fill('test');

View File

@@ -1,14 +1,13 @@
import { expect, test } from '@/e2e/fixtures/auth-hook'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils'; import { createUser, generateTestEmail } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import test, { expect } from '@playwright/test';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { test('should be able to ban and unban a user', async ({ page }) => {
await gotoAuthURL(page); const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
}); await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
test('should be able to ban and unban a user', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();

View File

@@ -1,12 +1,26 @@
import { expect, test } from '@/e2e/fixtures/auth-hook'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils'; import { createUser, generateTestEmail } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { let page: Page;
await gotoAuthURL(page);
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
}); });
test('should create a user', async ({ authenticatedNhostPage: page }) => { test.beforeEach(async () => {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should create a user', async () => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();
@@ -17,9 +31,7 @@ test('should create a user', async ({ authenticatedNhostPage: page }) => {
).toBeVisible(); ).toBeVisible();
}); });
test('should not be able to create a user with an existing email', async ({ test('should not be able to create a user with an existing email', async () => {
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();

View File

@@ -1,15 +1,26 @@
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
import { expect, test } from '@/e2e/fixtures/auth-hook'; let page: Page;
test.beforeEach(async ({ authenticatedNhostPage: page }) => { test.beforeAll(async ({ browser }) => {
await gotoAuthURL(page); page = await browser.newPage();
}); });
test('should be able to delete a user', async ({ test.beforeEach(async () => {
authenticatedNhostPage: page, const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
}) => { await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should be able to delete a user', async () => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();
@@ -41,9 +52,7 @@ test('should be able to delete a user', async ({
).not.toBeVisible(); ).not.toBeVisible();
}); });
test('should be able to delete a user from the details page', async ({ test('should be able to delete a user from the details page', async () => {
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();

View File

@@ -1,14 +1,26 @@
import { expect, test } from '@/e2e/fixtures/auth-hook'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils'; import { createUser, generateTestEmail } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { let page: Page;
await gotoAuthURL(page);
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
}); });
test('should be able to edit user roles from the details page', async ({ test.beforeEach(async () => {
authenticatedNhostPage: page, const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
}) => { await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should be able to edit user roles from the details page', async () => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();

View File

@@ -1,14 +1,26 @@
import { expect, test } from '@/e2e/fixtures/auth-hook'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils'; import { createUser, generateTestEmail } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { let page: Page;
await gotoAuthURL(page);
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
}); });
test('should be able to verify the email of a user', async ({ test.beforeEach(async () => {
authenticatedNhostPage: page, const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
}) => { await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should be able to verify the email of a user', async () => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();
@@ -38,9 +50,7 @@ test('should be able to verify the email of a user', async ({
).toBeChecked(); ).toBeChecked();
}); });
test('should be able to verify the phone number of a user', async ({ test('should be able to verify the phone number of a user', async () => {
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail(); const email = generateTestEmail();
const password = faker.internet.password(); const password = faker.internet.password();
const phoneNumber = faker.phone.number(); const phoneNumber = faker.phone.number();

View File

@@ -1,18 +1,35 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test } from '@/e2e/fixtures/auth-hook'; import { navigateToProject, prepareTable } from '@/e2e/utils';
import { prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case'; import { snakeCase } from 'snake-case';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`; const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute); await page.goto(databaseRoute);
await page.waitForURL(databaseRoute); await page.waitForURL(databaseRoute);
}); });
test('should create a simple table', async ({ test.afterAll(async () => {
authenticatedNhostPage: page, await page.close();
}) => { });
test('should create a simple table', async () => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -40,9 +57,7 @@ test('should create a simple table', async ({
).toBeVisible(); ).toBeVisible();
}); });
test('should create a table with unique constraints', async ({ test('should create a table with unique constraints', async () => {
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -71,9 +86,7 @@ test('should create a table with unique constraints', async ({
).toBeVisible(); ).toBeVisible();
}); });
test('should create a table with nullable columns', async ({ test('should create a table with nullable columns', async () => {
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -102,9 +115,7 @@ test('should create a table with nullable columns', async ({
).toBeVisible(); ).toBeVisible();
}); });
test('should create a table with an identity column', async ({ test('should create a table with an identity column', async () => {
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -137,9 +148,7 @@ test('should create a table with an identity column', async ({
).toBeVisible(); ).toBeVisible();
}); });
test('should create table with foreign key constraint', async ({ test('should create table with foreign key constraint', async () => {
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -212,9 +221,7 @@ test('should create table with foreign key constraint', async ({
).toBeVisible(); ).toBeVisible();
}); });
test('should not be able to create a table with a name that already exists', async ({ test('should not be able to create a table with a name that already exists', async () => {
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();

View File

@@ -1,17 +1,35 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { deleteTable, prepareTable } from '@/e2e/utils'; import { deleteTable, navigateToProject, prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@/e2e/fixtures/auth-hook'; import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case'; import { snakeCase } from 'snake-case';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`; const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute); await page.goto(databaseRoute);
await page.waitForURL(databaseRoute); await page.waitForURL(databaseRoute);
}); });
test('should delete a table', async ({ authenticatedNhostPage: page }) => { test.afterAll(async () => {
await page.close();
});
test('should delete a table', async () => {
const tableName = snakeCase(faker.lorem.words(3)); const tableName = snakeCase(faker.lorem.words(3));
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
@@ -47,9 +65,7 @@ test('should delete a table', async ({ authenticatedNhostPage: page }) => {
).not.toBeVisible(); ).not.toBeVisible();
}); });
test('should not be able to delete a table if other tables have foreign keys referencing it', async ({ test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
authenticatedNhostPage: page,
}) => {
test.setTimeout(60000); test.setTimeout(60000);
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();

View File

@@ -1,18 +1,39 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test } from '@/e2e/fixtures/auth-hook'; import {
import { clickPermissionButton, prepareTable } from '@/e2e/utils'; clickPermissionButton,
navigateToProject,
prepareTable,
} from '@/e2e/utils';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case'; import { snakeCase } from 'snake-case';
test.beforeEach(async ({ authenticatedNhostPage: page }) => { let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`; const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute); await page.goto(databaseRoute);
await page.waitForURL(databaseRoute); await page.waitForURL(databaseRoute);
}); });
test('should create a table with role permissions to select row', async ({ test.afterAll(async () => {
authenticatedNhostPage: page, await page.close();
}) => { });
test('should create a table with role permissions to select row', async () => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -58,9 +79,7 @@ test('should create a table with role permissions to select row', async ({
).toBeVisible(); ).toBeVisible();
}); });
test('should create a table with role permissions and a custom check to select rows', async ({ test('should create a table with role permissions and a custom check to select rows', async () => {
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click(); await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible(); await expect(page.getByText(/create a new table/i)).toBeVisible();

View File

@@ -4,7 +4,7 @@
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. * Name of the workspace 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;
@@ -40,7 +40,3 @@ export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL;
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 = JSON.parse(freeUserEmails);

View File

@@ -1,23 +0,0 @@
import { TEST_DASHBOARD_URL, TEST_PERSONAL_ORG_SLUG } from '@/e2e/env';
import { type Page, test as base } from '@playwright/test';
export const AUTH_CONTEXT = 'e2e/.auth/user.json';
export const test = base.extend<{ authenticatedNhostPage: Page }>({
authenticatedNhostPage: async ({ browser }, use) => {
const context = await browser.newContext({ storageState: AUTH_CONTEXT });
const page = await context.newPage();
await page.goto('/');
await page.waitForURL(
`${TEST_DASHBOARD_URL}/orgs/${TEST_PERSONAL_ORG_SLUG}/projects`,
{ waitUntil: 'networkidle' },
);
await use(page);
// update the context to get the new refresh token
await page.waitForLoadState('networkidle');
await page.context().storageState({ path: AUTH_CONTEXT });
await page.close();
},
});
export { expect } from '@playwright/test';

View File

@@ -1,8 +1,15 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test } from '@/e2e/fixtures/auth-hook'; import type { Page } from '@playwright/test';
import { navigateToProject } from '@/e2e/utils'; import { expect, test } from '@playwright/test';
import { navigateToProject } from '../utils';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
await page.goto('/');
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await navigateToProject({ await navigateToProject({
page, page,
orgSlug: TEST_ORGANIZATION_SLUG, orgSlug: TEST_ORGANIZATION_SLUG,
@@ -10,9 +17,11 @@ test.beforeEach(async ({ authenticatedNhostPage: page }) => {
}); });
}); });
test('should show the navtree with all links visible', async ({ test.afterAll(async () => {
authenticatedNhostPage: page, await page.close();
}) => { });
test('should show the navtree with all links visible', async () => {
const navLocator = page.getByLabel('Navigation Tree'); const navLocator = page.getByLabel('Navigation Tree');
await expect(navLocator).toBeVisible(); await expect(navLocator).toBeVisible();
@@ -33,20 +42,16 @@ test('should show the navtree with all links visible', async ({
'Settings', 'Settings',
]; ];
// eslint-disable-next-line no-restricted-syntax
for (const linkName of links) { for (const linkName of links) {
const link = const link =
linkName === 'Settings' linkName === 'Settings'
? page.getByRole('link', { name: linkName }).first() ? page.getByRole('link', { name: linkName }).first()
: page.getByRole('link', { name: linkName }); : page.getByRole('link', { name: linkName });
// eslint-disable-next-line no-await-in-loop
await expect(link).toBeVisible(); await expect(link).toBeVisible();
} }
}); });
test("should show the project's region and subdomain", async ({ test("should show the project's region and subdomain", async () => {
authenticatedNhostPage: page,
}) => {
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText( await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
/frankfurt \(eu-central-1\)/i, /frankfurt \(eu-central-1\)/i,
); );
@@ -55,9 +60,7 @@ test("should show the project's region and subdomain", async ({
).toHaveText(/[a-z]{20}/i); ).toHaveText(/[a-z]{20}/i);
}); });
test('should not have a GitHub repository connected', async ({ test('should not have a GitHub repository connected', async () => {
authenticatedNhostPage: page,
}) => {
await expect( await expect(
page.getByRole('button', { name: /connect to github/i }).first(), page.getByRole('button', { name: /connect to github/i }).first(),
).toBeVisible(); ).toBeVisible();

View File

@@ -1,15 +1,33 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test } from '@/e2e/fixtures/auth-hook'; import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { navigateToProject } from '../utils';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`; const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`;
await page.goto(runRoute); await page.goto(runRoute);
await page.waitForURL(runRoute); await page.waitForURL(runRoute);
}); });
test('should create and delete a run service', async ({ test.afterAll(async () => {
authenticatedNhostPage: page, await page.close();
}) => { });
test('should create and delete a run service', async () => {
await page.getByRole('button', { name: 'Add service' }).first().click(); await page.getByRole('button', { name: 'Add service' }).first().click();
await expect(page.getByText(/create a new service/i)).toBeVisible(); await expect(page.getByText(/create a new service/i)).toBeVisible();
await page.getByPlaceholder(/service name/i).click(); await page.getByPlaceholder(/service name/i).click();

View File

@@ -1,55 +0,0 @@
/* 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');
}
});

View File

@@ -1,23 +1,49 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env'; import {
import { expect, test as teardown } from '@/e2e/fixtures/auth-hook'; TEST_DASHBOARD_URL,
TEST_ORGANIZATION_SLUG,
TEST_PROJECT_SUBDOMAIN,
} from '@/e2e/env';
import { navigateToProject } from '@/e2e/utils';
import { type Page, expect, test as teardown } from '@playwright/test';
let page: Page;
teardown.beforeAll(async ({ browser }) => {
const context = await browser.newContext({
baseURL: TEST_DASHBOARD_URL,
storageState: 'e2e/.auth/user.json',
});
page = await context.newPage();
});
teardown.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
teardown.beforeEach(async ({ authenticatedNhostPage: page }) => {
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`; const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute); await page.goto(databaseRoute);
await page.waitForURL(databaseRoute); await page.waitForURL(databaseRoute);
}); });
teardown( teardown.afterAll(async () => {
'clean up database tables', await page.close();
async ({ authenticatedNhostPage: page }) => { });
await page.getByRole('link', { name: /sql editor/i }).click();
await page.waitForURL( teardown('clean up database tables', async () => {
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`, await page.getByRole('link', { name: /sql editor/i }).click();
);
const inputField = page.locator('[contenteditable]'); await page.waitForURL(
await inputField.fill(` `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
);
const inputField = page.locator('[contenteditable]');
await inputField.fill(`
DO $$ DECLARE DO $$ DECLARE
tablename text; tablename text;
BEGIN BEGIN
@@ -30,7 +56,6 @@ teardown(
END $$; END $$;
`); `);
await page.locator('button[type="button"]', { hasText: /run/i }).click(); await page.locator('button[type="button"]', { hasText: /run/i }).click();
await expect(page.getByText(/success/i)).toBeVisible(); await expect(page.getByText(/success/i)).toBeVisible();
}, });
);

View File

@@ -1,142 +0,0 @@
import { expect, test } from '@/e2e/fixtures/auth-hook';
import {
getCardExpiration,
getFreeUserStarterOrgSlug,
getNewOrgSlug,
getNewProjectName,
getNewProjectSlug,
getOrgSlugFromUrl,
getProjectSlugFromUrl,
gotoUrl,
loginWithFreeUser,
setNewOrgSlug,
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('should create a new project', async () => {
await gotoUrl(page, `/orgs/${getFreeUserStarterOrgSlug()}/projects/new`);
const projectName = faker.lorem.words(3).slice(0, 32);
await page.getByLabel('Project Name').fill(projectName);
await page.getByText('Create Project').click();
expect(page.getByText('Creating the project...')).toBeVisible();
expect(page.getByText('Internal info')).toBeVisible();
await page.waitForSelector('button:has-text("Upgrade project")', {
timeout: 180000,
});
const newProjectSlug = getProjectSlugFromUrl(page.url());
setNewProjectSlug(newProjectSlug);
setNewProjectName(projectName);
});
test('should upgrade the project', async () => {
await gotoUrl(
page,
`/orgs/${getFreeUserStarterOrgSlug()}/projects/${getNewProjectSlug()}`,
);
const upgradeProject = page.getByText('Upgrade project');
expect(upgradeProject).toBeVisible();
await upgradeProject.click();
await page.waitForSelector('h2:has-text("Upgrade project")');
await page.getByRole('button', { name: 'Continue' }).click();
await page.waitForSelector('h2:has-text("New Organization")');
const newOrgName = faker.lorem.words(3);
await page.getByLabel('Organization Name').fill(newOrgName);
await page.getByText('Create organization').click();
await page.waitForSelector('button:has-text("Create organization")', {
state: 'hidden',
});
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 local testing 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 });
// local Comment end
await stripeFrame
.getByTestId('hosted-payment-submit-button')
.scrollIntoViewIfNeeded();
await stripeFrame
.getByTestId('hosted-payment-submit-button')
.click({ force: true });
await page.waitForSelector('h2:has-text("Upgrade project")');
await page.waitForSelector(
'div:has-text("Organization created successfully.")',
);
await page.waitForSelector(
'div:has-text("Project has been upgraded successfully!")',
);
page.getByRole('button', { name: 'Create project' });
await page.waitForSelector(`div:has-text("${newOrgName}")`);
await page.waitForSelector(`p:has-text("${getNewProjectName()}")`);
setNewOrgSlug(getOrgSlugFromUrl(page.url()));
});
test('should delete the new organization', async () => {
await gotoUrl(page, `/orgs/${getNewOrgSlug()}/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(`div:has-text("Personal Organization")`);
});

View File

@@ -1,12 +1,5 @@
import {
TEST_FREE_USER_EMAILS,
TEST_ORGANIZATION_SLUG,
TEST_PROJECT_SUBDOMAIN,
TEST_USER_PASSWORD,
} from '@/e2e/env';
import { faker } from '@faker-js/faker'; import { faker } from '@faker-js/faker';
import { type Page, expect } 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. * Open a project by navigating to the project's overview page.
@@ -218,97 +211,3 @@ export async function clickPermissionButton({
.locator('button') .locator('button')
.click(); .click();
} }
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' });
}
export async function gotoUrl(page: Page, url: string) {
await page.url;
await page.goto(url);
await page.waitForURL(url, { waitUntil: 'networkidle' });
}
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].replace('/projects', '');
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() * 5);
}
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();
expect(
await page.getByRole('button', { name: 'Create project' }),
).not.toBeVisible();
await page.waitForSelector('h2:has-text("Welcome to")', { timeout: 20000 });
setFreeUserStarterOrgSlug(getOrgSlugFromUrl(await page.url()));
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@nhost/dashboard", "name": "@nhost/dashboard",
"version": "2.33.0", "version": "2.24.0",
"private": true, "private": true,
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
@@ -16,10 +16,8 @@
"storybook": "start-storybook -p 6006 -s public", "storybook": "start-storybook -p 6006 -s public",
"build-storybook": "build-storybook", "build-storybook": "build-storybook",
"install-browsers": "pnpm playwright install && pnpm playwright install-deps", "install-browsers": "pnpm playwright install && pnpm playwright install-deps",
"e2e:tests": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts -x", "e2e": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
"e2e": "pnpm e2e:tests --project=main", "e2e-local": "pnpm install-browsers && pnpm playwright test --config=playwright.local.config.ts"
"e2e:local": "pnpm e2e:tests --project=local",
"e2e:upgrade-project": "pnpm e2e:tests --project=upgrade-project"
}, },
"dependencies": { "dependencies": {
"@apollo/client": "^3.9.9", "@apollo/client": "^3.9.9",
@@ -39,13 +37,13 @@
"@heroicons/react": "^1.0.6", "@heroicons/react": "^1.0.6",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@iarna/toml": "^2.2.5", "@iarna/toml": "^2.2.5",
"@icons-pack/react-simple-icons": "^9.6.0",
"@marsidev/react-turnstile": "^1.0.2", "@marsidev/react-turnstile": "^1.0.2",
"@mui/base": "5.0.0-beta.31", "@mui/base": "5.0.0-beta.31",
"@mui/material": "^5.15.14", "@mui/material": "^5.15.14",
"@mui/system": "^5.15.14", "@mui/system": "^5.15.14",
"@mui/x-date-pickers": "^5.0.20", "@mui/x-date-pickers": "^5.0.20",
"@nhost/nhost-js-beta": "npm:@nhost/nhost-js@5.0.0-beta.7", "@nhost/nextjs": "workspace:*",
"@nhost/react-apollo": "workspace:*",
"@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-accordion": "^1.2.1",
"@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-alert-dialog": "^1.1.2",
"@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-checkbox": "^1.1.2",
@@ -62,7 +60,6 @@
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-tooltip": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.2",
"@segment/analytics-next": "^1.77.0", "@segment/analytics-next": "^1.77.0",
"@simplewebauthn/browser": "^9.0.1",
"@stripe/react-stripe-js": "^2.6.2", "@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^1.54.2", "@stripe/stripe-js": "^1.54.2",
"@tailwindcss/forms": "^0.5.7", "@tailwindcss/forms": "^0.5.7",
@@ -88,10 +85,9 @@
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"graphql-ws": "^5.16.0", "graphql-ws": "^5.16.0",
"just-kebab-case": "^4.2.0", "just-kebab-case": "^4.2.0",
"jwt-decode": "^4.0.0",
"lodash.debounce": "^4.0.8", "lodash.debounce": "^4.0.8",
"lucide-react": "^0.416.0", "lucide-react": "^0.416.0",
"next": "^14.2.26", "next": "^14.2.22",
"next-nprogress-bar": "^2.3.13", "next-nprogress-bar": "^2.3.13",
"next-seo": "^6.5.0", "next-seo": "^6.5.0",
"next-themes": "^0.3.0", "next-themes": "^0.3.0",
@@ -100,7 +96,7 @@
"react": "18.2.0", "react": "18.2.0",
"react-children-utilities": "^2.10.0", "react-children-utilities": "^2.10.0",
"react-complex-tree": "^2.4.5", "react-complex-tree": "^2.4.5",
"react-day-picker": "9.6.3", "react-day-picker": "8.10.1",
"react-dom": "18.2.0", "react-dom": "18.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-hook-form": "^7.53.0", "react-hook-form": "^7.53.0",
@@ -109,13 +105,14 @@
"react-is": "18.2.0", "react-is": "18.2.0",
"react-loading-skeleton": "^2.2.0", "react-loading-skeleton": "^2.2.0",
"react-markdown": "^9.0.1", "react-markdown": "^9.0.1",
"react-merge-refs": "^3.0.2", "react-merge-refs": "^1.1.0",
"react-resizable-layout": "^0.7.2", "react-resizable-layout": "^0.7.2",
"react-table": "^7.8.0", "react-table": "^7.8.0",
"recoil": "^0.7.7", "recoil": "^0.7.7",
"recoil-persist": "^5.1.0", "recoil-persist": "^5.1.0",
"rehype-highlight": "^7.0.0", "rehype-highlight": "^7.0.0",
"remark-gfm": "^4.0.0", "remark-gfm": "^4.0.0",
"shell-quote": "^1.8.1",
"slugify": "^1.6.6", "slugify": "^1.6.6",
"stripe": "^10.17.0", "stripe": "^10.17.0",
"tailwind-merge": "^1.14.0", "tailwind-merge": "^1.14.0",
@@ -160,6 +157,7 @@
"@types/react": "^18.2.73", "@types/react": "^18.2.73",
"@types/react-dom": "^18.2.23", "@types/react-dom": "^18.2.23",
"@types/react-table": "^7.7.20", "@types/react-table": "^7.7.20",
"@types/shell-quote": "^1.7.5",
"@types/testing-library__jest-dom": "^5.14.9", "@types/testing-library__jest-dom": "^5.14.9",
"@types/uuid": "^9.0.8", "@types/uuid": "^9.0.8",
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
@@ -198,7 +196,7 @@
"tailwindcss": "^3.4.12", "tailwindcss": "^3.4.12",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths-webpack-plugin": "^4.1.0", "tsconfig-paths-webpack-plugin": "^4.1.0",
"vite": "^5.4.19", "vite": "^5.4.12",
"vite-tsconfig-paths": "^4.3.2", "vite-tsconfig-paths": "^4.3.2",
"vitest": "^0.32.4" "vitest": "^0.32.4"
}, },

View File

@@ -17,7 +17,7 @@ export default defineConfig({
reporter: 'html', reporter: 'html',
use: { use: {
actionTimeout: 0, actionTimeout: 0,
trace: 'retain-on-failure', trace: 'on-first-retry',
baseURL: process.env.NHOST_TEST_DASHBOARD_URL, baseURL: process.env.NHOST_TEST_DASHBOARD_URL,
launchOptions: { launchOptions: {
slowMo: 500, slowMo: 500,
@@ -34,28 +34,13 @@ export default defineConfig({
testMatch: ['**/teardown/*.teardown.ts'], testMatch: ['**/teardown/*.teardown.ts'],
}, },
{ {
name: 'main', name: 'chromium',
use: { use: {
...devices['Desktop Chrome'], ...devices['Desktop Chrome'],
storageState: 'e2e/.auth/user.json', storageState: 'e2e/.auth/user.json',
}, },
dependencies: ['setup'], dependencies: ['setup'],
testIgnore: ['upgrade-project.test.ts', 'cli-local-dashboard.test.ts'], grepInvert: [/Local Dashboard CLI e2e tests/],
},
{
name: 'local',
use: {
...devices['Desktop Chrome'],
baseURL: '', // Local dashboard URL
},
testMatch: 'cli-local-dashboard.test.ts',
},
{
name: 'upgrade-project',
testMatch: 'upgrade-project.test.ts',
use: {
...devices['Desktop Chrome'],
},
}, },
], ],
}); });

View File

@@ -0,0 +1,31 @@
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/**'],
},
],
});

View File

@@ -0,0 +1,102 @@
import { Link } from '@/components/ui/v2/Link';
import { Text } from '@/components/ui/v2/Text';
import type { DetailedHTMLProps, HTMLProps } from 'react';
import { twMerge } from 'tailwind-merge';
export interface ContactUsProps
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
isTeam?: boolean;
isOwner?: boolean;
}
export default function FeedbackForm({
className,
isTeam,
isOwner,
...props
}: ContactUsProps) {
return (
<div
className={twMerge(
'grid max-w-md grid-flow-row gap-2 px-5 py-4',
className,
)}
{...props}
>
<Text variant="h3" component="h2">
Contact us
</Text>
{isTeam && isOwner && (
<Text>
If this is a new Team project, or you need to manage members, reach
out to us on discord or via email at{' '}
<Link
href="mailto:support@nhost.io"
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
support@nhost.io
</Link>{' '}
so we can have your dedicated channel set up.
</Text>
)}
{isTeam && !isOwner && (
<Text>
As part of a team plan you can reach out to us on the private channel
for this workspace. If you haven&apos;t been added to the channel, ask
the workspace owner to add you.
</Text>
)}
<Text>
To report issues with Nhost, please open a GitHub issue in the{' '}
<Link
href="https://github.com/nhost/nhost/issues/new"
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
nhost/nhost
</Link>{' '}
repository.
</Text>
<Text>
For issues related to the CLI, please visit the{' '}
<Link
href="https://github.com/nhost/cli/issues/new"
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
nhost/cli
</Link>{' '}
repository.
</Text>
<Text>
If you need assistance or have any questions, feel free to join us on{' '}
<Link
href="https://discord.com/invite/9V7Qb2U"
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
Discord
</Link>
. Alternatively, if you prefer, you can also open a{' '}
<Link
href="https://github.com/nhost/nhost/discussions/new/choose"
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
GitHub discussion
</Link>
.
</Text>
<Text>We&apos;re here to help, so don&apos;t hesitate to reach out!</Text>
</div>
);
}

View File

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

View File

@@ -1,177 +0,0 @@
import { isTZDate } from '@/components/common/TimePicker/time-picker-utils';
import { render, screen, TestUserEvent, waitFor } from '@/tests/testUtils';
import { isBefore, startOfDay } from 'date-fns-v4';
import { useState } from 'react';
import { TZDate } from 'react-day-picker';
import { vi } from 'vitest';
import DateTimePicker, { type DateTimePickerProps } from './DateTimePicker';
vi.mock('@/utils/timezoneUtils', async () => {
const actualTimezoneUtils = await vi.importActual<any>(
'@/utils/timezoneUtils',
);
return {
...actualTimezoneUtils,
guessTimezone: () => 'Europe/Helsinki',
};
});
const earliestBackupDate = '2025-03-13T02:00:05.000Z';
function TestComponent(
props: Omit<DateTimePickerProps, 'dateTime' | 'onDateTimeChange'>,
) {
const [dateTime, setDateTime] = useState(earliestBackupDate);
function isCalendarDayDisabled(date: Date | TZDate) {
if (isTZDate(date)) {
const utcDay = new Date(date.getTime()).toISOString();
const tzDate = new TZDate(utcDay, date.timeZone);
const earliestBackupDateInTz = new TZDate(
earliestBackupDate,
date.timeZone,
);
return isBefore(startOfDay(tzDate), startOfDay(earliestBackupDateInTz));
}
return isBefore(
startOfDay(new Date(date.getTime()).toISOString()),
startOfDay(earliestBackupDate),
);
}
return (
<>
<h1 data-testid="utcDate">{dateTime}</h1>
<DateTimePicker
{...props}
isCalendarDayDisabled={isCalendarDayDisabled}
dateTime={dateTime}
onDateTimeChange={setDateTime}
/>
</>
);
}
describe('DateTimePicker', () => {
test('when the date changes datetime is emitted in utc string format', async () => {
render(<TestComponent />);
const user = new TestUserEvent();
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
expect(
await screen.findByRole('button', { name: 'Select' }),
).toBeInTheDocument();
expect(await 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'));
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '11');
const minutesInput = await screen.getByLabelText('Minutes');
await user.type(minutesInput, '12');
const secondsInput = await screen.getByLabelText('Seconds');
await user.type(secondsInput, '13');
await user.click(await screen.getByRole('button', { name: 'Select' }));
await waitFor(async () =>
expect(
await screen.queryByRole('button', { name: 'Select' }),
).not.toBeInTheDocument(),
);
expect(screen.getByTestId('utcDate')).toHaveTextContent(
'2025-04-13T08:12:13.000Z',
);
});
test('timezone can be changed and the calendar is updated', async () => {
await waitFor(() => render(<TestComponent withTimezone />));
const user = new TestUserEvent();
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
expect(
await screen.findByTestId('timezoneSettingsButton'),
).toBeInTheDocument();
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+02:00',
);
expect(await screen.getByText('12')).toBeDisabled();
await user.click(await screen.findByTestId('timezoneSettingsButton'));
const tzInput = await screen.findByPlaceholderText('Search timezones...');
expect(tzInput).toBeInTheDocument();
await user.type(tzInput, 'America/Chicago{ArrowDown}{Enter}');
expect(
await screen.queryByPlaceholderText('Search timezones...'),
).not.toBeInTheDocument();
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC-05:00',
);
const selectedDay = screen.getByText('12');
expect(selectedDay).not.toBeDisabled();
expect(await screen.getByText('11')).toBeDisabled();
const gridCell = selectedDay.closest('[role="gridcell"]');
expect(gridCell).toHaveClass('[&>button]:bg-primary');
});
test('Displays the correct time zone offset when changing the selected date from standard time (ST) to daylight saving time (DST)', async () => {
await waitFor(() => render(<TestComponent withTimezone />));
const user = new TestUserEvent();
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
expect(
await screen.findByTestId('timezoneSettingsButton'),
).toBeInTheDocument();
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+02:00',
);
expect(await 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('18'));
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+03:00',
);
await user.click(
screen.getByRole('button', { name: 'Go to the Previous Month' }),
);
expect(await screen.getByText('March 2025')).toBeInTheDocument();
await user.click(await screen.getByText('21'));
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
'Timezone: UTC+02:00',
);
});
});

View File

@@ -27,6 +27,8 @@ export interface DateTimePickerProps {
align?: 'start' | 'center' | 'end'; align?: 'start' | 'center' | 'end';
validateDateFn?: (date: Date) => string; validateDateFn?: (date: Date) => string;
} }
// in: UTC datetime
// out: UTC dateTime
function DateTimePicker({ function DateTimePicker({
dateTime, dateTime,
@@ -47,10 +49,6 @@ function DateTimePicker({
}); });
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [timezone, setTimezone] = useState(
() => defaultTimezone || guessTimezone(),
);
function emitNewDateTime() { function emitNewDateTime() {
onDateTimeChange(new Date(date.getTime()).toISOString()); onDateTimeChange(new Date(date.getTime()).toISOString());
} }
@@ -75,7 +73,6 @@ function DateTimePicker({
function handleTimezoneChange(newTimezone: string) { function handleTimezoneChange(newTimezone: string) {
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone); const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
setTimezone(newTimezone);
setDate(newDateWithTimezone); setDate(newDateWithTimezone);
} }
@@ -83,7 +80,6 @@ function DateTimePicker({
if (!newOpenState) { if (!newOpenState) {
if (withTimezone) { if (withTimezone) {
const tz = defaultTimezone || guessTimezone(); const tz = defaultTimezone || guessTimezone();
setTimezone(tz);
setDate(new TZDate(dateTime, tz)); setDate(new TZDate(dateTime, tz));
} }
setDate(parseISO(dateTime)); setDate(parseISO(dateTime));
@@ -96,8 +92,6 @@ function DateTimePicker({
setOpen(false); setOpen(false);
} }
const selectedDateInUTC = new Date(date.getTime()).toISOString();
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss'); const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
const errorText = validateDateFn?.(date); const errorText = validateDateFn?.(date);
@@ -107,7 +101,6 @@ function DateTimePicker({
<Popover open={open} onOpenChange={handleOpenChange}> <Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
data-testid="dateTimePickerTrigger"
variant="outline" variant="outline"
className={cn( className={cn(
'w-full justify-between text-left font-normal', 'w-full justify-between text-left font-normal',
@@ -120,17 +113,15 @@ function DateTimePicker({
<CalendarIcon className="h-4 w-4" /> <CalendarIcon className="h-4 w-4" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}> <PopoverContent className="w-auto p-0" align={align}>
<div className="flex"> <div className="flex">
<div className="flex"> <div className="flex">
<Calendar <Calendar
mode="single" mode="single"
selected={date} selected={date}
defaultMonth={date}
onSelect={(d) => handleSelect(d)} onSelect={(d) => handleSelect(d)}
initialFocus
disabled={isCalendarDayDisabled} disabled={isCalendarDayDisabled}
timeZone={timezone}
/> />
<div className="flex flex-col justify-between"> <div className="flex flex-col justify-between">
<div> <div>
@@ -140,7 +131,7 @@ function DateTimePicker({
{withTimezone && ( {withTimezone && (
<div className="border-t border-border p-3"> <div className="border-t border-border p-3">
<TimezoneSettings <TimezoneSettings
dateTime={selectedDateInUTC} dateTime={dateTime}
onTimezoneChange={handleTimezoneChange} onTimezoneChange={handleTimezoneChange}
/> />
</div> </div>

View File

@@ -18,23 +18,16 @@ function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
setTimezone(tz.value); setTimezone(tz.value);
onTimezoneChange?.(tz.value); onTimezoneChange?.(tz.value);
} }
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO'); const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
return ( return (
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<span>Timezone: {utcOffset}</span> Timezone: {utcOffset}{' '}
<TimezonePicker <TimezonePicker
dateTime={dateTime} dateTime={dateTime}
selectedTimezone={selectedTimezone} selectedTimezone={selectedTimezone}
onTimezoneSelect={handleTimezoneSelect} onTimezoneSelect={handleTimezoneSelect}
button={ button={
<Button <Button variant="ghost" size="icon">
variant="ghost"
size="icon"
aria-label="Open timezone settings"
data-testid="timezoneSettingsButton"
>
<Settings2 className="h-4 w-4 dark:text-foreground" /> <Settings2 className="h-4 w-4 dark:text-foreground" />
</Button> </Button>
} }

View File

@@ -0,0 +1,200 @@
import { Box } from '@/components/ui/v2/Box';
import { Button } from '@/components/ui/v2/Button';
import { Text } from '@/components/ui/v2/Text';
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
import {
GetAllWorkspacesAndProjectsDocument,
GetWorkspaceMemberInvitesToManageDocument,
useGetWorkspaceMemberInvitesToManageQuery,
} from '@/generated/graphql';
import { useSubmitState } from '@/hooks/useSubmitState';
import { nhost } from '@/utils/nhost';
import { triggerToast } from '@/utils/toast';
import { useApolloClient } from '@apollo/client';
import { alpha } from '@mui/system';
import { useUserData } from '@nhost/nextjs';
import { useRouter } from 'next/router';
import { useEffect } from 'react';
export default function InviteNotification() {
const user = useUserData();
const isPlatform = useIsPlatform();
const client = useApolloClient();
const router = useRouter();
const { submitState, setSubmitState } = useSubmitState();
const { submitState: ignoreState, setSubmitState: setIgnoreState } =
useSubmitState();
// @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page?
const {
data,
loading,
error,
refetch: refetchInvitations,
startPolling,
} = useGetWorkspaceMemberInvitesToManageQuery({
variables: {
userId: user?.id,
},
skip: !isPlatform || !user,
});
useEffect(() => {
startPolling(15000);
}, [startPolling]);
if (loading) {
return null;
}
if (error) {
// TODO: Throw error instead and wrap this component in an ErrorBoundary
// that would handle the error
return null;
}
if (!data || data.workspaceMemberInvites.length === 0) {
return null;
}
const handleInviteAccept = async (
_event: React.SyntheticEvent<HTMLButtonElement>,
invite: (typeof data.workspaceMemberInvites)[number],
) => {
setSubmitState({
error: null,
loading: true,
});
const { res, error: acceptError } = await nhost.functions.call(
'/accept-workspace-invite',
{
workspaceMemberInviteId: invite.id,
isAccepted: true,
},
);
if (res?.status !== 200) {
triggerToast('An error occurred when trying to accept the invitation.');
return setSubmitState({
error: new Error(acceptError.message),
loading: false,
});
}
await client.refetchQueries({
include: [
GetAllWorkspacesAndProjectsDocument,
GetWorkspaceMemberInvitesToManageDocument,
],
});
await router.push(`/${invite.workspace.slug}`);
await refetchInvitations();
triggerToast('Workspace invite accepted');
return setSubmitState({
error: null,
loading: false,
});
};
async function handleIgnoreInvitation(
inviteId: (typeof data.workspaceMemberInvites)[number]['id'],
) {
setIgnoreState({
loading: true,
error: null,
});
const { error: ignoreError } = await nhost.functions.call(
'/accept-workspace-invite',
{
workspaceMemberInviteId: inviteId,
isAccepted: false,
},
);
if (ignoreError) {
triggerToast('An error occurred when trying to ignore the invitation.');
setIgnoreState({
loading: false,
error: new Error(ignoreError.message),
});
return;
}
// just refetch all data
await client.refetchQueries({
include: [
GetAllWorkspacesAndProjectsDocument,
GetWorkspaceMemberInvitesToManageDocument,
],
});
setIgnoreState({
loading: false,
error: null,
});
}
return (
<Box
className="absolute right-10 z-50 mt-14 w-workspaceSidebar rounded-lg px-6 py-6 text-left"
sx={{
backgroundColor: (theme) =>
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.700',
borderWidth: (theme) => (theme.palette.mode === 'dark' ? 1 : 0),
borderColor: (theme) =>
theme.palette.mode === 'dark' ? theme.palette.grey[400] : 'none',
}}
>
{data?.workspaceMemberInvites?.map(
(invite: (typeof data.workspaceMemberInvites)[number]) => (
<div key={invite.id} className="grid grid-flow-row gap-4 text-center">
<div className="grid grid-flow-row gap-1">
<Text variant="h3" component="h2" sx={{ color: 'common.white' }}>
You have been invited to
</Text>
<Text variant="h3" component="p" sx={{ color: 'common.white' }}>
{invite.workspace.name}
</Text>
</div>
<div className="grid grid-flow-row gap-2">
<Button
onClick={(e: React.SyntheticEvent<HTMLButtonElement>) =>
handleInviteAccept(e, invite)
}
loading={submitState.loading}
>
Accept Invite
</Button>
<Button
variant="outlined"
color="secondary"
sx={{
color: 'common.white',
'&:hover': {
backgroundColor: (theme) =>
alpha(theme.palette.common.white, 0.05),
},
'&:focus': {
backgroundColor: (theme) =>
alpha(theme.palette.common.white, 0.1),
},
}}
onClick={() => handleIgnoreInvitation(invite.id)}
loading={ignoreState.loading}
>
Ignore Invite
</Button>
</div>
</div>
),
)}
</Box>
);
}

View File

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

View File

@@ -1,518 +0,0 @@
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();
});
});
});

View File

@@ -1,87 +0,0 @@
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;

View File

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

View File

@@ -7,6 +7,7 @@ import { List } from '@/components/ui/v2/List';
import { ListItem } from '@/components/ui/v2/ListItem'; import { ListItem } from '@/components/ui/v2/ListItem';
import { Text } from '@/components/ui/v2/Text'; import { Text } from '@/components/ui/v2/Text';
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs'; import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
import {} from '@/utils/__generated__/graphql';
import { Divider } from '@mui/material'; import { Divider } from '@mui/material';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';
import Image from 'next/image'; import Image from 'next/image';

View File

@@ -1,6 +1,7 @@
import { render, screen, TestUserEvent } from '@/tests/testUtils'; import { render, screen } from '@/tests/orgs/testUtils';
import { guessTimezone } from '@/utils/timezoneUtils'; import { guessTimezone } from '@/utils/timezoneUtils';
import { TZDate } from '@date-fns/tz'; import { TZDate } from '@date-fns/tz';
import userEvent from '@testing-library/user-event';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { format } from 'date-fns-v4'; import { format } from 'date-fns-v4';
import { useState } from 'react'; import { useState } from 'react';
@@ -35,7 +36,7 @@ describe('TimePicker', () => {
expect(await screen.getByText(/Time:/i)).toHaveTextContent( expect(await screen.getByText(/Time:/i)).toHaveTextContent(
'Time: 03:00:05', 'Time: 03:00:05',
); );
const user = new TestUserEvent(); const user = userEvent.setup();
const hoursInput = await screen.getByLabelText('Hours'); const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '18'); await user.type(hoursInput, '18');
expect(await screen.getByText(/Time:/i)).toHaveTextContent( expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -45,7 +46,7 @@ describe('TimePicker', () => {
test('only valid hours(0-23), minutes(0-59) and seconds(0-59) are allowed', async () => { test('only valid hours(0-23), minutes(0-59) and seconds(0-59) are allowed', async () => {
render(<TestComponent dateTime="2025-03-10T03:00:05" />); render(<TestComponent dateTime="2025-03-10T03:00:05" />);
const user = new TestUserEvent(); const user = userEvent.setup();
const hoursInput = await screen.getByLabelText('Hours'); const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '30'); await user.type(hoursInput, '30');
expect(await screen.getByText(/Time:/i)).toHaveTextContent( expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -60,7 +61,7 @@ describe('TimePicker', () => {
test('Updates only the minutes of the date object', async () => { test('Updates only the minutes of the date object', async () => {
render(<TestComponent dateTime="2025-03-10T03:00:05" />); render(<TestComponent dateTime="2025-03-10T03:00:05" />);
const user = new TestUserEvent(); const user = userEvent.setup();
const minutesInput = await screen.getByLabelText('Minutes'); const minutesInput = await screen.getByLabelText('Minutes');
await user.type(minutesInput, '44'); await user.type(minutesInput, '44');
expect(await screen.getByText(/Time:/i)).toHaveTextContent( expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -70,7 +71,7 @@ describe('TimePicker', () => {
test('Updates only the seconds of the date object', async () => { test('Updates only the seconds of the date object', async () => {
render(<TestComponent dateTime="2025-03-10T03:00:05" />); render(<TestComponent dateTime="2025-03-10T03:00:05" />);
const user = new TestUserEvent(); const user = userEvent.setup();
const secondsInput = await screen.getByLabelText('Seconds'); const secondsInput = await screen.getByLabelText('Seconds');
await user.type(secondsInput, '11'); await user.type(secondsInput, '11');
expect(await screen.getByText(/Time:/i)).toHaveTextContent( expect(await screen.getByText(/Time:/i)).toHaveTextContent(
@@ -83,7 +84,7 @@ describe('TimePicker', () => {
expect(await screen.getByText(/Date class:/i)).toHaveTextContent( expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
'Date class: TZDate', 'Date class: TZDate',
); );
const user = new TestUserEvent(); const user = userEvent.setup();
const hoursInput = await screen.getByLabelText('Hours'); const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '18'); await user.type(hoursInput, '18');

View File

@@ -3,12 +3,12 @@ import { Input } from '@/components/ui/v3/input';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import React from 'react'; import React from 'react';
import { import {
type Period,
type TimePickerType,
copyDate, copyDate,
getArrowByType, getArrowByType,
getDateByType, getDateByType,
setDateByType, setDateByType,
type Period,
type TimePickerType,
} from './time-picker-utils'; } from './time-picker-utils';
export interface TimePickerInputProps export interface TimePickerInputProps

View File

@@ -229,7 +229,7 @@ export function getArrowByType(
} }
} }
export function isTZDate(date: Date | TZDate): date is TZDate { function isTZDate(date: Date | TZDate): date is TZDate {
return date instanceof TZDate; return date instanceof TZDate;
} }

View File

@@ -9,26 +9,6 @@ interface Props {
dateTime: string; dateTime: string;
} }
function getOrderedTimezones(dateTime: string, selectedTimezone: string) {
const [utcTimezone, browserTimezone, ...timezones] =
createTimezoneOptions(dateTime);
let orderedTimezones = [...timezones];
if (
selectedTimezone !== browserTimezone.value &&
selectedTimezone !== 'UTC'
) {
const selectedTimezoneOption = timezones.find(
(tz) => tz.value === selectedTimezone,
);
orderedTimezones = [
selectedTimezoneOption,
...timezones.filter((tz) => tz.value !== selectedTimezone),
];
}
return [utcTimezone, browserTimezone, ...orderedTimezones];
}
function TimezonePicker({ function TimezonePicker({
selectedTimezone, selectedTimezone,
onTimezoneSelect, onTimezoneSelect,
@@ -36,10 +16,9 @@ function TimezonePicker({
dateTime, dateTime,
}: Props) { }: Props) {
const timezoneOptions = useMemo( const timezoneOptions = useMemo(
() => getOrderedTimezones(dateTime, selectedTimezone), () => createTimezoneOptions(dateTime),
[dateTime, selectedTimezone], [dateTime],
); );
return ( return (
<VirtualizedCombobox <VirtualizedCombobox
options={timezoneOptions} options={timezoneOptions}
@@ -48,7 +27,6 @@ function TimezonePicker({
searchPlaceholder="Search timezones..." searchPlaceholder="Search timezones..."
button={button} button={button}
side="right" side="right"
width="370px"
/> />
); );
} }

View File

@@ -3,7 +3,7 @@ import { Box } from '@/components/ui/v2/Box';
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon'; import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
import { Link } from '@/components/ui/v2/Link'; import { Link } from '@/components/ui/v2/Link';
import { Text } from '@/components/ui/v2/Text'; import { Text } from '@/components/ui/v2/Text';
import { TransferOrUpgradeProjectDialog } from '@/features/orgs/components/common/TransferOrUpgradeProjectDialog'; import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
import { useState } from 'react'; import { useState } from 'react';
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton'; import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
@@ -51,7 +51,7 @@ export default function UpgradeToProBanner({
<div className="flex flex-col gap-2 space-y-2 lg:flex-row lg:items-center lg:space-x-2 lg:space-y-0"> <div className="flex flex-col gap-2 space-y-2 lg:flex-row lg:items-center lg:space-x-2 lg:space-y-0">
<OpenTransferDialogButton onClick={handleTransferDialogOpen} /> <OpenTransferDialogButton onClick={handleTransferDialogOpen} />
<TransferOrUpgradeProjectDialog <TransferProjectDialog
open={transferProjectDialogOpen} open={transferProjectDialogOpen}
setOpen={setTransferProjectDialogOpen} setOpen={setTransferProjectDialogOpen}
/> />

View File

@@ -105,12 +105,20 @@ function VirtualizedCommand<O extends Option>({
} }
}; };
React.useEffect(() => {
if (selectedOption) {
const option = filteredOptions.find(
(opt) => opt.value === selectedOption,
);
if (option) {
const index = filteredOptions.indexOf(option);
setFocusedIndex(index);
}
}
}, [selectedOption, filteredOptions, virtualizer]);
return ( return (
<Command <Command shouldFilter={false} onKeyDown={handleKeyDown}>
shouldFilter={false}
onKeyDown={handleKeyDown}
value={selectedOption}
>
<CommandInput onValueChange={handleSearch} placeholder={placeholder} /> <CommandInput onValueChange={handleSearch} placeholder={placeholder} />
<CommandList <CommandList
ref={parentRef} ref={parentRef}
@@ -137,6 +145,7 @@ function VirtualizedCommand<O extends Option>({
filteredOptions[virtualOption.index].key ?? filteredOptions[virtualOption.index].key ??
filteredOptions[virtualOption.index].value filteredOptions[virtualOption.index].value
} }
disabled={isKeyboardNavActive}
className={cn( className={cn(
'absolute left-0 top-0 w-full bg-transparent', 'absolute left-0 top-0 w-full bg-transparent',
focusedIndex === virtualOption.index && focusedIndex === virtualOption.index &&

View File

@@ -0,0 +1,67 @@
import { render, screen } from '@/tests/testUtils';
import type { Column } from 'react-table';
import { expect, test } from 'vitest';
import DataGrid from './DataGrid';
interface MockDataDetails {
id: number;
name: string;
}
const mockColumns: Column<MockDataDetails>[] = [
{ id: 'id', Header: 'ID', accessor: 'id' },
{ id: 'name', Header: 'Name', accessor: 'name' },
];
const mockData: MockDataDetails[] = [
{ id: 1, name: 'foo' },
{ id: 2, name: 'bar' },
];
test('should render an empty state if columns are not available', () => {
render(<DataGrid columns={[]} data={[]} />);
expect(screen.getByText(/columns not found/i)).toBeInTheDocument();
});
test('should render columns and empty state message if data is unavailable', () => {
render(<DataGrid columns={mockColumns} data={[]} />);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('columnheader', { name: /id/i })).toBeInTheDocument();
expect(
screen.getByRole('columnheader', { name: /name/i }),
).toBeInTheDocument();
expect(screen.getByText(/no data is available/i)).toBeInTheDocument();
});
test('should render custom empty state message if data is unavailable', () => {
const customEmptyStateMessage = 'custom empty state message';
render(
<DataGrid
columns={mockColumns}
data={[]}
emptyStateMessage={customEmptyStateMessage}
/>,
);
expect(screen.getByText(customEmptyStateMessage)).toBeInTheDocument();
});
test('should display a loading indicator', async () => {
render(<DataGrid columns={mockColumns} data={[]} loading />);
// Activity indicator is not immediately displayed, so we need to wait
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
});
test('should render data if provided', () => {
render(<DataGrid columns={mockColumns} data={mockData} />);
expect(screen.getAllByRole('row')).toHaveLength(2);
expect(screen.getByRole('cell', { name: /1/i })).toBeInTheDocument();
expect(screen.getByRole('cell', { name: /foo/i })).toBeInTheDocument();
});

View File

@@ -0,0 +1,185 @@
import type { UseDataGridOptions } from '@/components/dataGrid/DataGrid/useDataGrid';
import { DataGridBody } from '@/components/dataGrid/DataGridBody';
import { DataGridConfigProvider } from '@/components/dataGrid/DataGridConfigProvider';
import { DataGridFrame } from '@/components/dataGrid/DataGridFrame';
import type { DataGridHeaderProps } from '@/components/dataGrid/DataGridHeader';
import { DataGridHeader } from '@/components/dataGrid/DataGridHeader';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Box } from '@/components/ui/v2/Box';
import { DataBrowserEmptyState } from '@/features/database/dataGrid/components/DataBrowserEmptyState';
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
import type { ForwardedRef } from 'react';
import { forwardRef, useEffect, useRef } from 'react';
import mergeRefs from 'react-merge-refs';
import type { Column, Row, SortingRule, TableOptions } from 'react-table';
import { twMerge } from 'tailwind-merge';
import useDataGrid from './useDataGrid';
export interface DataGridProps<TColumnData extends object>
extends Omit<UseDataGridOptions<TColumnData>, 'tableRef'> {
/**
* Available columns.
*/
columns: Column<TColumnData>[];
/**
* Data to be displayed in the table.
*/
data: any[];
/**
* Text to be displayed when no data is available in the data grid.
*
* @default null
*/
emptyStateMessage?: string;
/**
* Additional configuration options for the `react-table` hook.
*/
options?: Omit<TableOptions<TColumnData>, 'columns' | 'data'>;
/**
* Additional data grid controls. This component will be part of the Data Grid
* context, so it can use Data Grid configuration.
*/
controls?:
| React.ReactNode
| ((selectedFlatRows: Row<TColumnData>[]) => React.ReactNode);
/**
* Function to be called when columns are sorted in the table.
*/
onSort?: (args: SortingRule<TColumnData>[]) => void;
/**
* Function to be called when the user wants to insert a new row.
*/
onInsertRow?: VoidFunction;
/**
* Function to be called when the user wants to insert a new column.
*/
onInsertColumn?: VoidFunction;
/**
* Function to be called when the user wants to remove a column.
*/
onRemoveColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
/**
* Function to be called when the user wants to edit a column.
*/
onEditColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
/**
* Determines whether or not data is loading.
*/
loading?: boolean;
/**
* Class name to be applied to the data grid.
*/
className?: string;
/**
* Sort configuration.
*/
sortBy?: SortingRule<TColumnData>[];
/**
* Props to be passed to the `DataGridHeader` component.
*/
headerProps?: DataGridHeaderProps<TColumnData>;
}
function DataGrid<TColumnData extends object>(
{
columns,
data,
allowSelection,
allowSort,
allowResize,
emptyStateMessage,
options = {},
headerProps,
controls,
sortBy,
onSort,
onInsertRow,
onInsertColumn,
onEditColumn,
onRemoveColumn,
loading,
className,
}: DataGridProps<TColumnData>,
ref: ForwardedRef<HTMLDivElement>,
) {
const tableRef = useRef<HTMLDivElement>();
const { toggleAllRowsSelected, setSortBy, ...dataGridProps } =
useDataGrid<TColumnData>({
columns: columns || [],
data: data || [],
allowSelection,
allowSort,
allowResize,
...options,
});
useEffect(() => {
if (!sortBy && setSortBy) {
setSortBy([]);
}
}, [setSortBy, sortBy]);
useEffect(() => {
if (onSort && allowSort) {
onSort(dataGridProps.state.sortBy);
if (toggleAllRowsSelected) {
toggleAllRowsSelected(false);
}
}
}, [allowSort, dataGridProps.state.sortBy, onSort, toggleAllRowsSelected]);
return (
<DataGridConfigProvider
toggleAllRowsSelected={toggleAllRowsSelected}
setSortBy={setSortBy}
tableRef={tableRef}
{...dataGridProps}
>
<>
{controls}
{columns.length === 0 && !loading && (
<DataBrowserEmptyState
title="Columns not found"
description="Please create a column before adding data to the table."
/>
)}
{columns.length > 0 && (
<Box
ref={mergeRefs([ref, tableRef])}
sx={{ backgroundColor: 'background.default' }}
className={twMerge(
'overflow-x-auto',
!loading && 'h-full',
className,
)}
>
<DataGridFrame>
<DataGridHeader
onInsertColumn={onInsertColumn}
onEditColumn={onEditColumn}
onRemoveColumn={onRemoveColumn}
{...headerProps}
/>
<DataGridBody
emptyStateMessage={emptyStateMessage}
loading={loading}
onInsertRow={onInsertRow}
allowInsertColumn={Boolean(onRemoveColumn)}
/>
</DataGridFrame>
</Box>
)}
{loading && <ActivityIndicator delay={1000} className="my-4" />}
</>
</DataGridConfigProvider>
);
}
export default forwardRef(DataGrid) as <TColumnData extends object>(
props: DataGridProps<TColumnData> & { ref?: ForwardedRef<HTMLDivElement> },
) => ReturnType<typeof DataGrid>;

View File

@@ -0,0 +1,4 @@
export * from './DataGrid';
export { default as DataGrid } from './DataGrid';
export * from './useDataGrid';
export { default as useDataGrid } from './useDataGrid';

View File

@@ -0,0 +1,110 @@
import { Checkbox } from '@/components/ui/v2/Checkbox';
import type { MutableRefObject } from 'react';
import { useMemo } from 'react';
import type { PluginHook, TableInstance, TableOptions } from 'react-table';
import {
useBlockLayout,
useResizeColumns,
useRowSelect,
useSortBy,
useTable,
} from 'react-table';
export interface UseDataGridBaseOptions {
/**
* Determines whether data grid columns are selectable.
*
* @default false
*/
allowSelection?: boolean;
/**
* Determines whether data grid columns are sortable.
*
* @default false
*/
allowSort?: boolean;
/**
* Determine whether data grid columns are resizable.
*
* @default false
*/
allowResize?: boolean;
/**
* Reference to the data grid root element.
*/
tableRef?: MutableRefObject<HTMLDivElement>;
}
export type UseDataGridOptions<T extends object = {}> = TableOptions<T> &
UseDataGridBaseOptions;
export type UseDataGridReturn<T extends object = {}> = TableInstance<T> &
UseDataGridBaseOptions;
export default function useDataGrid<T extends object>(
{ allowSelection, allowSort, allowResize, ...options }: UseDataGridOptions<T>,
...plugins: PluginHook<T>[]
): UseDataGridReturn<T> {
const defaultColumn = useMemo(
() => ({
width: 32,
minWidth: 32,
Cell: ({ value }: { value: any }) => (
<span className="truncate">
{typeof value === 'object' ? JSON.stringify(value) : value}
</span>
),
}),
[],
);
const pluginHooks = [
useBlockLayout,
useResizeColumns,
useSortBy,
useRowSelect,
];
const tableData = useTable<T>(
{
defaultColumn,
...options,
},
...pluginHooks,
...plugins,
(hooks) =>
allowSelection
? hooks.visibleColumns.push((columns) => [
{
id: 'selection',
Header: ({ rows, getToggleAllRowsSelectedProps }: any) => (
<Checkbox
disabled={rows.length === 0}
{...getToggleAllRowsSelectedProps({ style: null })}
style={{
...getToggleAllRowsSelectedProps().style,
cursor: rows.length === 0 ? 'default' : 'pointer',
}}
/>
),
Cell: ({ row }: any) => {
const originalValue = row.original as any;
return (
<Checkbox
{...row.getToggleRowSelectedProps()}
// disable selection if row is just a upload preview
checked={originalValue.uploading ? false : row.isSelected}
disabled={originalValue.uploading}
/>
);
},
disableSortBy: true,
disableResizing: true,
},
...columns,
])
: hooks.visibleColumns,
);
return { ...tableData, allowSort, allowResize, allowSelection };
}

View File

@@ -0,0 +1,315 @@
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
import { DataGridCell } from '@/components/dataGrid/DataGridCell';
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { Button } from '@/components/ui/v2/Button';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
import type { DetailedHTMLProps, HTMLProps, KeyboardEvent } from 'react';
import { Fragment, useMemo, useRef } from 'react';
import type { Row } from 'react-table';
import { twMerge } from 'tailwind-merge';
export interface DataGridBodyProps<T extends object>
extends Omit<
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
'children'
>,
Pick<DataGridProps<T>, 'onInsertRow' | 'emptyStateMessage' | 'loading'> {
/**
* Determines whether column insertion is allowed.
*/
allowInsertColumn?: boolean;
}
interface InsertPlaceholderTableRowProps extends BoxProps {
/**
* Function to be called when the user wants to insert a new row.
*/
onInsertRow: VoidFunction;
}
function InsertPlaceholderTableRow({
onInsertRow,
...props
}: InsertPlaceholderTableRowProps) {
return (
<Box className="h-12 border-b-1 border-r-1" {...props}>
<Button
onClick={onInsertRow}
variant="borderless"
color="secondary"
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none"
startIcon={
<PlusIcon className="h-4 w-4" sx={{ color: 'text.secondary' }} />
}
>
Insert New Row
</Button>
</Box>
);
}
// TODO: Get rid of Data Browser related code from here. This component should
// be generic and not depend on Data Browser related data types and logic.
export default function DataGridBody<T extends object>({
emptyStateMessage = 'No data is available',
loading,
onInsertRow,
allowInsertColumn,
...props
}: DataGridBodyProps<T>) {
const { getTableBodyProps, totalColumnsWidth, rows, prepareRow, columns } =
useDataGridConfig<T>();
const SELECTION_CELL_WIDTH = 32;
const ADD_COLUMN_CELL_WIDTH = 100;
const bodyRef = useRef<HTMLDivElement>();
const primaryAndUniqueKeys = useMemo(
() =>
columns
.filter(
(column: DataBrowserGridColumn<T>) =>
column.isPrimary || column.isUnique,
)
.map((column) => column.id),
[columns],
);
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>, row: Row<T>) {
const { id: rowId } = row;
const cellId = document.activeElement.id;
const currentRow = bodyRef.current.children.namedItem(rowId);
if (event.key === 'ArrowUp') {
event.preventDefault();
if (!currentRow.previousElementSibling) {
return;
}
const cellInPreviousRow =
currentRow.previousElementSibling.children.namedItem(cellId);
if (cellInPreviousRow instanceof HTMLElement) {
cellInPreviousRow.scrollIntoView({
block: 'nearest',
});
cellInPreviousRow.focus();
}
}
if (event.key === 'ArrowDown') {
event.preventDefault();
if (!currentRow.nextElementSibling) {
return;
}
const cellInNextRow =
currentRow.nextElementSibling.children.namedItem(cellId);
if (cellInNextRow instanceof HTMLElement) {
cellInNextRow.scrollIntoView({ block: 'nearest' });
cellInNextRow.focus();
}
}
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
let previousFocusableCellInRow: HTMLElement;
let previousFocusableCellInRowFound = false;
currentRow.childNodes.forEach((node) => {
if (node === currentRow.children.namedItem(cellId)) {
previousFocusableCellInRowFound = true;
}
if (
node instanceof HTMLElement &&
node.tabIndex > -1 &&
!previousFocusableCellInRowFound
) {
previousFocusableCellInRow = node;
}
});
if (previousFocusableCellInRow) {
event.preventDefault();
previousFocusableCellInRow.scrollIntoView({
block: 'nearest',
inline: 'center',
});
previousFocusableCellInRow.focus();
}
}
if (
event.key === 'ArrowRight' ||
(!event.shiftKey && event.key === 'Tab')
) {
let nextFocusableCellInRow: HTMLElement;
let nextFocusableCellInRowFound = false;
currentRow.childNodes.forEach((node) => {
if (
node instanceof HTMLElement &&
node.tabIndex > -1 &&
parseInt(node.id, 10) > parseInt(cellId, 10) &&
!nextFocusableCellInRowFound
) {
nextFocusableCellInRowFound = true;
nextFocusableCellInRow = node;
}
});
if (nextFocusableCellInRow) {
event.preventDefault();
nextFocusableCellInRow.scrollIntoView({
block: 'nearest',
inline: 'center',
});
nextFocusableCellInRow.focus();
}
}
}
const getBackgroundCellColor = (
row: Row<T>,
column: DataBrowserGridColumn<T>,
) => {
// Grey out files not uploaded
if (!row.values.isUploaded) {
return 'grey.200';
}
if (column.isDisabled) {
return 'grey.100';
}
return 'background.paper';
};
return (
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
{rows.length === 0 && !loading && (
<div className="flex flex-nowrap pr-5">
{onInsertRow ? (
<InsertPlaceholderTableRow
style={{
width: allowInsertColumn
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
: totalColumnsWidth - SELECTION_CELL_WIDTH,
}}
onInsertRow={onInsertRow}
/>
) : (
<Box
className="inline-flex h-12 items-center border-b-1 border-r-1 px-2 py-1.5 text-xs"
sx={{ color: 'text.secondary' }}
style={{
width: allowInsertColumn
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
: totalColumnsWidth,
}}
>
{emptyStateMessage}
</Box>
)}
</div>
)}
{rows.map((row, index) => {
let rowKey = index.toString();
if (primaryAndUniqueKeys && primaryAndUniqueKeys.length > 0) {
rowKey = primaryAndUniqueKeys
.map((key) => row.values[key])
.filter(Boolean)
.join('-');
} else {
rowKey = `${index}-${Object.keys(row.values)
.map((key) => String(row.values[key]))
.join('-')}`;
}
prepareRow(row);
const rowProps = row.getRowProps({
style: {
width: allowInsertColumn
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
: totalColumnsWidth,
},
});
return (
<Fragment key={rowKey.toString()}>
<div
{...rowProps}
id={row.id}
className="flex scroll-mt-10"
role="row"
onKeyDown={(event) => handleKeyDown(event, row)}
tabIndex={-1}
>
{row.cells.map((cell, cellIndex) => {
const column = cell.column as DataBrowserGridColumn<T>;
const isCellDisabled =
cell.value !== 0 &&
!cell.value &&
column.type !== 'boolean' &&
column.id !== 'selection' &&
column.isDisabled;
return (
<DataGridCell
{...cell.getCellProps({
style: {
display: 'inline-flex',
alignItems: 'center',
},
})}
cell={cell}
sx={{
backgroundColor: getBackgroundCellColor(row, column),
color: isCellDisabled ? 'text.secondary' : 'text.primary',
}}
className={twMerge(
'h-12 font-display text-xs motion-safe:transition-colors',
'border-b-1 border-r-1',
'scroll-ml-8 scroll-mt-[57px]',
column.id === 'selection' &&
'sticky left-0 z-20 justify-center px-0',
)}
isEditable={!column.isDisabled && column.isEditable}
id={cellIndex.toString()}
key={column.id}
>
{cell.render('Cell')}
</DataGridCell>
);
})}
{allowInsertColumn && (
<Box className="h-12 w-25 border-b-1 border-r-1" />
)}
</div>
{onInsertRow && index === rows.length - 1 && (
<InsertPlaceholderTableRow
{...rowProps}
key=""
onInsertRow={onInsertRow}
/>
)}
</Fragment>
);
})}
</div>
);
}

View File

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

View File

@@ -0,0 +1,121 @@
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
import { Dropdown } from '@/components/ui/v2/Dropdown';
import type { MouseEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
import { twMerge } from 'tailwind-merge';
export type DataGridBooleanCellProps<TData extends object> =
CommonDataGridCellProps<TData, boolean | null>;
export default function DataGridBooleanCell<TData extends object>({
onSave,
optimisticValue,
temporaryValue,
onTemporaryValueChange,
cell: {
column: { isNullable },
},
}: DataGridBooleanCellProps<TData>) {
const {
inputRef,
isEditing,
focusCell,
editCell,
cancelEditCell,
isSelected,
} = useDataGridCell<HTMLInputElement>();
async function handleMenuClick(
event: MouseEvent<HTMLLIElement> | ReactKeyboardEvent<HTMLLIElement>,
value: boolean | null,
) {
event.stopPropagation();
await onSave(value);
cancelEditCell();
}
async function handleMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown'
) {
event.stopPropagation();
}
// We need to restore the temporary value, because editing was cancelled
if (event.key === 'Escape' && onTemporaryValueChange) {
event.stopPropagation();
onTemporaryValueChange(optimisticValue);
cancelEditCell();
}
if (event.key === 'Tab' && onSave) {
await onSave(temporaryValue);
cancelEditCell();
}
}
function handleTemporaryValueChange(value: boolean | null) {
if (onTemporaryValueChange) {
onTemporaryValueChange(value);
}
}
return isSelected ? (
<Dropdown.Root id="boolean-data-editor" className="h-full w-full">
<Dropdown.Trigger
id="boolean-trigger"
className={twMerge(
'h-full w-full border-none p-0 outline-none',
isEditing && 'p-1.5',
)}
ref={inputRef}
onClick={editCell}
autoFocus={false}
sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}
>
<ReadOnlyToggle checked={optimisticValue} />
</Dropdown.Trigger>
<Dropdown.Content
menu
disablePortal
onKeyDown={handleMenuKeyDown}
PaperProps={{ className: 'w-[200px]' }}
TransitionProps={{ onExited: focusCell }}
>
<Dropdown.Item
selected={optimisticValue === true}
onKeyUp={() => handleTemporaryValueChange(true)}
onClick={(event) => handleMenuClick(event, true)}
>
<ReadOnlyToggle checked />
</Dropdown.Item>
<Dropdown.Item
selected={optimisticValue === false}
onKeyUp={() => handleTemporaryValueChange(false)}
onClick={(event) => handleMenuClick(event, false)}
>
<ReadOnlyToggle checked={false} />
</Dropdown.Item>
{isNullable && (
<Dropdown.Item
selected={optimisticValue === null}
onKeyUp={() => handleTemporaryValueChange(null)}
onClick={(event) => handleMenuClick(event, null)}
>
<ReadOnlyToggle checked={null} />
</Dropdown.Item>
)}
</Dropdown.Content>
</Dropdown.Root>
) : (
<ReadOnlyToggle checked={optimisticValue} />
);
}

View File

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

View File

@@ -0,0 +1,381 @@
import { useDialog } from '@/components/common/DialogProvider';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { Tooltip, useTooltip } from '@/components/ui/v2/Tooltip';
import type {
ColumnType,
DataBrowserGridCell,
DataBrowserGridCellProps,
} from '@/features/database/dataGrid/types/dataBrowser';
import { triggerToast } from '@/utils/toast';
import type {
FocusEvent,
JSXElementConstructor,
KeyboardEvent,
MouseEvent,
ReactElement,
ReactNode,
ReactPortal,
} from 'react';
import {
Children,
cloneElement,
isValidElement,
useEffect,
useState,
} from 'react';
import { twMerge } from 'tailwind-merge';
import DataGridCellProvider from './DataGridCellProvider';
import useDataGridCell from './useDataGridCell';
export interface CommonDataGridCellProps<TData extends object, TValue = any>
extends DataBrowserGridCellProps<TData, TValue> {
/**
* Function that is called when the cell is saved.
*/
onSave?: (value: TValue, options?: { reset: boolean }) => Promise<void>;
/**
* Optimistic value for the cell.
*/
optimisticValue?: TValue;
/**
* Function to be called when the optimistic value should be changed.
*/
onOptimisticValueChange?: (value: TValue) => void;
/**
* Temporary value for the cell. This is used for storing the current input
* value, that should be later saved as an optimistic value before saving the
* data.
*/
temporaryValue?: TValue;
/**
* Function to be called when the temporary value should be changed.
*/
onTemporaryValueChange?: (value: TValue) => void;
}
export interface DataGridCellProps<TData extends object, TValue = unknown>
extends BoxProps {
/**
* Current cell's props.
*/
cell: DataBrowserGridCell<TData, TValue>;
/**
* Determines whether the cell is editable.
*/
isEditable?: boolean;
/**
* Determines the column's type.
*/
columnType?: ColumnType;
}
function DataGridCellContent<TData extends object = {}, TValue = unknown>({
isEditable,
children,
className,
cell: {
value: originalValue,
column: { onCellEdit, id, isNullable, isPrimary, type },
row,
},
...props
}: DataGridCellProps<TData, TValue>) {
const { openAlertDialog } = useDialog();
const {
title: tooltipTitle,
open: tooltipOpen,
openTooltip,
closeTooltip,
resetTooltipTitle,
} = useTooltip();
const [optimisticValue, setOptimisticValue] = useState<TValue>(originalValue);
const [temporaryValue, setTemporaryValue] = useState<TValue>(originalValue);
useEffect(() => {
setOptimisticValue(originalValue);
setTemporaryValue(originalValue);
}, [originalValue]);
const {
cellRef,
inputRef,
focusCell,
focusInput,
blurInput,
clickInput,
isEditing,
isSelected,
selectCell,
deselectCell,
cancelEditCell,
editCell,
focusPrevCell,
focusNextCell,
} = useDataGridCell();
function activateInput() {
if (isPrimary) {
openTooltip("Primary keys can't be edited.");
return;
}
editCell();
if (type === 'boolean') {
clickInput();
} else {
focusInput();
}
}
async function handleClick(event: MouseEvent<HTMLDivElement>) {
if (!isEditable || isEditing || isPrimary) {
return;
}
if (event.detail === 2 && type !== 'boolean') {
editCell();
await focusInput();
}
}
function handleFocus() {
if (!isEditable) {
return;
}
selectCell();
}
async function handleSave(
value: TValue,
options: { reset: boolean } = { reset: false },
) {
if (!onCellEdit) {
return;
}
const normalizedValue =
value !== null && typeof value === 'object'
? JSON.stringify(value)
: String(value);
const normalizedOptimisticValue =
optimisticValue !== null && typeof optimisticValue === 'object'
? JSON.stringify(optimisticValue)
: String(optimisticValue);
// We are making sure that optimistic value is not equal to the current
// value. If it is, we are not going to save the value.
if (
normalizedValue.replace(/\n/gi, '\\n') ===
normalizedOptimisticValue.replace(/\n/gi, '\\n') &&
!options.reset
) {
return;
}
// In case of an error, we need to reset optimistic value
const latestOptimisticValue = optimisticValue;
setOptimisticValue(value);
try {
const data = await onCellEdit({
row,
columnsToUpdate: {
[id]: {
value: !options.reset ? value : undefined,
reset: options.reset,
},
},
});
// Syncing optimistic value with server-side value
setTemporaryValue(data.original[id.toString()]);
setOptimisticValue(data.original[id.toString()]);
} catch (error) {
triggerToast(`Error: ${error.message || 'Unknown error occurred.'}`);
// Resetting values
setTemporaryValue(latestOptimisticValue);
setOptimisticValue(latestOptimisticValue);
}
}
async function handleBlur(event: FocusEvent<HTMLDivElement>) {
// We are deselecting cell only if focus target is not a descendant of it.
if (!isEditable || event.currentTarget.contains(event.relatedTarget)) {
return;
}
await handleSave(temporaryValue);
closeTooltip();
deselectCell();
}
function resetCell() {
if (isPrimary) {
openTooltip('Primary keys are non-nullable.');
return;
}
if (!isNullable) {
openTooltip(
<span>
<strong>{id}</strong>
is non-nullable.
</span>,
);
return;
}
openAlertDialog({
title: 'Set value to null',
payload: (
<p>
Are you sure you want to set this cell to <strong>null</strong>?
</p>
),
props: {
primaryButtonText: 'Set to null',
primaryButtonColor: 'error',
onPrimaryAction: async () => {
await handleSave(null, { reset: true });
focusCell();
},
},
});
}
async function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (!isEditable) {
return;
}
if (event.key === 'Escape') {
closeTooltip();
}
// Resetting temporary value and focusing cell on Escape when input field is
// focused
if (event.key === 'Escape' && event.target === inputRef.current) {
setTemporaryValue(optimisticValue);
await focusCell();
cancelEditCell();
}
// Activating input field on Enter
if (event.key === 'Enter' && event.target === cellRef.current) {
activateInput();
}
// Focusing next cell on Tab
if (event.key === 'Tab' && !event.shiftKey) {
event.stopPropagation();
const nextCellAvailable = focusNextCell();
if (!nextCellAvailable) {
event.preventDefault();
event.stopPropagation();
await blurInput();
await focusCell();
}
}
// Focusing previous cell on Shift-Tab
if (event.key === 'Tab' && event.shiftKey) {
event.stopPropagation();
const prevCellAvailable = focusPrevCell();
if (!prevCellAvailable) {
event.preventDefault();
event.stopPropagation();
await blurInput();
await focusCell();
}
}
// Initiating cell reset when cell is focused
if (event.key === 'Backspace' && event.target === cellRef.current) {
resetCell();
}
}
const content = (
<Box
ref={cellRef}
className={twMerge(
'relative grid h-full w-full cursor-default grid-flow-col items-center gap-1',
isEditable &&
'focus-within:outline-none focus-within:ring-0 focus:ring-0',
isSelected && 'shadow-outline',
isEditing ? 'p-0.5 shadow-outline-dark' : 'px-2 py-1.5',
className,
)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
tabIndex={isEditable ? 0 : undefined}
onClick={handleClick}
role="textbox"
sx={{ backgroundColor: 'transparent' }}
{...props}
>
{Children.map(
children,
(
child:
| ReactNode
| ReactPortal
| ReactElement<unknown, string | JSXElementConstructor<any>>,
) => {
if (!isValidElement(child)) {
return null;
}
return cloneElement(child, {
...child.props,
onSave: handleSave,
optimisticValue,
onOptimisticValueChange: setOptimisticValue,
temporaryValue,
onTemporaryValueChange: setTemporaryValue,
});
},
)}
</Box>
);
if (isEditable) {
return (
<Tooltip
disableHoverListener
disableFocusListener
open={tooltipOpen}
title={tooltipTitle || ''}
TransitionProps={{ onExited: resetTooltipTitle }}
>
{content}
</Tooltip>
);
}
return content;
}
export default function DataGridCell<TData extends object, TValue = unknown>(
props: DataGridCellProps<TData, TValue>,
) {
return (
<DataGridCellProvider>
<DataGridCellContent {...props} />
</DataGridCellProvider>
);
}

View File

@@ -0,0 +1,238 @@
import type { MutableRefObject, PropsWithChildren } from 'react';
import { createContext, useCallback, useMemo, useReducer, useRef } from 'react';
export interface DataGridCellContextProps<T extends HTMLElement> {
/**
* This `ref` should be attached to the cell element.
*/
cellRef: MutableRefObject<HTMLDivElement>;
/**
* This `ref` should be attached to the input element inside the data grid cell.
*/
inputRef: MutableRefObject<T>;
/**
* Determines whether or not the cell is currently being edited.
*/
isEditing: boolean;
/**
* Determines whether or not the cell is currently selected.
*/
isSelected: boolean;
/**
* Function to be called to start editing.
*/
editCell: VoidFunction;
/**
* Function to be called to cancel editing.
*/
cancelEditCell: VoidFunction;
/**
* Function to be called to select the cell, but not start editing.
*/
selectCell: VoidFunction;
/**
* Function to be called to deselect cell and cancel editing.
*/
deselectCell: VoidFunction;
/**
* Function to be called to focus cell.
*/
focusCell: () => Promise<void>;
/**
* Function to be called to blur cell.
*/
blurCell: () => Promise<void>;
/**
* Function to be called to programatically focus the input in the cell.
*/
focusInput: () => Promise<void>;
/**
* Function to be called to programatically blur the input in the cell.
*/
blurInput: () => Promise<void>;
/**
* Function to be called to programmatically click the input in the cell.
*/
clickInput: () => Promise<void>;
/**
* Function to be called to navigate to next cell if available.
*
* @returns `true` if there is a next cell to focus, `false` otherwise.
*/
focusNextCell: () => boolean;
/**
* Function to be called to navigate to previous cell if available.
*
* @returns `true` if there is a previous cell to focus, `false` otherwise.
*/
focusPrevCell: () => boolean;
}
export const DataGridCellContext =
createContext<DataGridCellContextProps<any>>(null);
interface EditAndSelectState {
isEditing: boolean;
isSelected: boolean;
}
type EditAndSelectAction =
| { type: 'EDIT' }
| { type: 'CANCEL_EDIT' }
| { type: 'SELECT' }
| { type: 'DESELECT' };
function editAndSelectCellReducer(
state: EditAndSelectState,
action: EditAndSelectAction,
): EditAndSelectState {
switch (action.type) {
case 'EDIT':
return { ...state, isEditing: true, isSelected: true };
case 'CANCEL_EDIT':
return { ...state, isEditing: false };
case 'SELECT':
return { ...state, isSelected: true };
case 'DESELECT':
return { ...state, isEditing: false, isSelected: false };
default:
return { ...state };
}
}
export default function DataGridCellProvider<TInput extends HTMLElement>({
children,
}: PropsWithChildren<unknown>) {
const cellRef = useRef<HTMLDivElement>();
const inputRef = useRef<TInput>();
const [{ isEditing, isSelected }, dispatch] = useReducer(
editAndSelectCellReducer,
{
isEditing: false,
isSelected: false,
},
);
function focusCell() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
cellRef.current?.focus();
resolve();
});
});
}
function deselectCell() {
dispatch({ type: 'DESELECT' });
}
const focusPrevCell = useCallback(() => {
const prevCellAvailable =
cellRef.current.previousElementSibling instanceof HTMLElement &&
cellRef.current.previousElementSibling.tabIndex > -1;
requestAnimationFrame(() => {
if (prevCellAvailable) {
(cellRef.current.previousElementSibling as HTMLElement).focus();
deselectCell();
}
});
return prevCellAvailable;
}, []);
const focusNextCell = useCallback(() => {
const nextCellAvailable =
cellRef.current.nextElementSibling instanceof HTMLElement &&
cellRef.current.nextElementSibling.tabIndex > -1;
requestAnimationFrame(() => {
if (nextCellAvailable) {
(cellRef.current.nextElementSibling as HTMLElement).focus();
deselectCell();
}
});
return nextCellAvailable;
}, []);
function blurCell() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
cellRef.current?.blur();
resolve();
});
});
}
function focusInput() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
inputRef.current?.focus();
resolve();
});
});
}
function blurInput() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
inputRef.current?.blur();
resolve();
});
});
}
function clickInput() {
return new Promise<void>((resolve) => {
requestAnimationFrame(() => {
inputRef.current?.click();
resolve();
});
});
}
function editCell() {
dispatch({ type: 'EDIT' });
}
function cancelEditCell() {
dispatch({ type: 'CANCEL_EDIT' });
}
function selectCell() {
dispatch({ type: 'SELECT' });
}
const value = useMemo(
() => ({
focusCell,
blurCell,
focusInput,
blurInput,
clickInput,
isEditing,
isSelected,
editCell,
cancelEditCell,
selectCell,
deselectCell,
cellRef,
inputRef,
focusPrevCell,
focusNextCell,
}),
[focusNextCell, focusPrevCell, isEditing, isSelected],
);
return (
<DataGridCellContext.Provider value={value}>
{children}
</DataGridCellContext.Provider>
);
}

View File

@@ -0,0 +1,5 @@
export * from './DataGridCell';
export { default as DataGridCell } from './DataGridCell';
export * from './DataGridCellProvider';
export { default as DataGridCellProvider } from './DataGridCellProvider';
export { default as useDataGridCell } from './useDataGridCell';

View File

@@ -0,0 +1,10 @@
import { useContext } from 'react';
import type { DataGridCellContextProps } from './DataGridCellProvider';
import { DataGridCellContext } from './DataGridCellProvider';
export default function useDataGridCell<TInput extends HTMLElement>() {
const context =
useContext<DataGridCellContextProps<TInput>>(DataGridCellContext);
return context;
}

View File

@@ -0,0 +1,6 @@
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
import { createContext } from 'react';
const DataGridConfigContext = createContext<Partial<UseDataGridReturn>>(null);
export default DataGridConfigContext;

View File

@@ -0,0 +1,16 @@
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
import type { PropsWithChildren } from 'react';
import DataGridConfigContext from './DataGridConfigContext';
export default function DataGridConfigProvider<T extends object = {}>({
children,
...value
}: PropsWithChildren<UseDataGridReturn<T>>) {
return (
<DataGridConfigContext.Provider
value={value as unknown as UseDataGridReturn<{}>}
>
{children}
</DataGridConfigContext.Provider>
);
}

View File

@@ -0,0 +1,3 @@
export { default as DataGridConfigContext } from './DataGridConfigContext';
export { default as DataGridConfigProvider } from './DataGridConfigProvider';
export { default as useDataGridConfig } from './useDataGridConfig';

View File

@@ -0,0 +1,15 @@
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
import { useContext } from 'react';
import DataGridConfigContext from './DataGridConfigContext';
export default function useDataGridConfig<T extends object = {}>() {
const context = useContext(DataGridConfigContext);
if (!context) {
throw new Error(
`useDataGridConfig must be used within a DataGridConfigContext`,
);
}
return context as unknown as UseDataGridReturn<T>;
}

View File

@@ -0,0 +1,166 @@
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
import { Input, inputClasses } from '@/components/ui/v2/Input';
import type { TextProps } from '@/components/ui/v2/Text';
import { Text } from '@/components/ui/v2/Text';
import { getDateComponents } from '@/utils/getDateComponents';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { twMerge } from 'tailwind-merge';
export interface DataGridDateCellProps<TData extends object>
extends CommonDataGridCellProps<TData, string> {
/**
* Props to be passed to date display.
*/
dateProps?: TextProps;
/**
* Props to be passed to time display.
*/
timeProps?: TextProps;
}
export default function DataGridDateCell<TData extends object>({
onSave,
optimisticValue,
temporaryValue,
onTemporaryValueChange,
cell: {
column: { specificType },
},
dateProps,
timeProps,
className,
}: DataGridDateCellProps<TData>) {
const { className: dateClassName, ...restDateProps } = dateProps || {};
const { className: timeClassName, ...restTimeProps } = timeProps || {};
// Note: No date (year-month-day) is saved for time / timetz columns, so we
// need to add it manually.
const date =
optimisticValue && specificType !== 'interval'
? new Date(
specificType === 'time' || specificType === 'timetz'
? `1970-01-01 ${optimisticValue}`
: optimisticValue,
)
: undefined;
const { year, month, day, hour, minute, second } = getDateComponents(date, {
adjustTimezone: ['date', 'timetz', 'timestamptz'].includes(specificType),
});
const { inputRef, focusCell, isEditing, cancelEditCell } =
useDataGridCell<HTMLInputElement>();
async function handleSave() {
if (onSave) {
await onSave(temporaryValue || '');
}
}
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'Backspace'
) {
event.stopPropagation();
}
if (event.key === 'Tab') {
await handleSave();
}
if (event.key === 'Enter') {
await handleSave();
await focusCell();
cancelEditCell();
}
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
if (event.target instanceof HTMLInputElement && onTemporaryValueChange) {
onTemporaryValueChange(event.target.value);
}
}
if (isEditing) {
return (
<Input
ref={inputRef}
value={
temporaryValue !== null && typeof temporaryValue !== 'undefined'
? temporaryValue
: ''
}
onKeyDown={handleKeyDown}
onChange={handleChange}
fullWidth
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
sx={{
[`&.${inputClasses.focused}`]: {
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
borderColor: 'transparent !important',
borderRadius: 0,
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? `${theme.palette.secondary[100]} !important`
: `${theme.palette.common.white} !important`,
},
[`& .${inputClasses.input}`]: {
backgroundColor: 'transparent',
},
}}
slotProps={{
inputWrapper: { className: 'h-full' },
input: { className: 'h-full' },
inputRoot: {
className:
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
},
}}
/>
);
}
if (!optimisticValue) {
return (
<Text className="truncate text-xs" color="secondary">
null
</Text>
);
}
if (specificType === 'interval') {
return <Text className="truncate text-xs">{optimisticValue}</Text>;
}
return (
<div className={twMerge('grid grid-flow-row', className)}>
{specificType !== 'time' && specificType !== 'timetz' && (
<Text
className={twMerge('truncate text-xs', dateClassName)}
{...restDateProps}
>
{[year, month, day].filter(Boolean).join('-')}
</Text>
)}
{specificType !== 'date' && (
<Text
className={twMerge('truncate text-xs', timeClassName)}
color={
specificType === 'time' || specificType === 'timetz'
? 'primary'
: 'secondary'
}
{...restTimeProps}
>
{[hour, minute, second].filter(Boolean).join(':')}
</Text>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,29 @@
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
import clsx from 'clsx';
import type { DetailedHTMLProps, HTMLProps } from 'react';
export type DataGridFrameProps = DetailedHTMLProps<
HTMLProps<HTMLDivElement>,
HTMLDivElement
>;
export default function DataGridFrame<T extends object>({
style,
children,
className,
...props
}: DataGridFrameProps) {
const { getTableProps } = useDataGridConfig<T>();
const { style: reactTableStyle, ...restTableProps } = getTableProps();
return (
<div
{...restTableProps}
{...props}
className={clsx('min-w-min', className)}
style={{ ...reactTableStyle, minWidth: undefined, ...style }}
>
{children}
</div>
);
}

View File

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

View File

@@ -0,0 +1,233 @@
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
import { Box } from '@/components/ui/v2/Box';
import { Button } from '@/components/ui/v2/Button';
import { Divider } from '@/components/ui/v2/Divider';
import { Dropdown } from '@/components/ui/v2/Dropdown';
import { ArrowDownIcon } from '@/components/ui/v2/icons/ArrowDownIcon';
import { ArrowUpIcon } from '@/components/ui/v2/icons/ArrowUpIcon';
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
import type { DetailedHTMLProps, HTMLProps } from 'react';
import { twMerge } from 'tailwind-merge';
export interface HeaderActionProps
extends DetailedHTMLProps<HTMLProps<HTMLElement>, HTMLElement> {}
export interface DataGridHeaderProps<T extends object>
extends Omit<
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
'children'
>,
Pick<
DataGridProps<T>,
'onRemoveColumn' | 'onEditColumn' | 'onInsertColumn'
> {
/**
* Props to be passed to component slots.
*/
componentsProps?: {
/**
* Props to be passed to the `Edit Column` header action item.
*/
editActionProps?: HeaderActionProps;
/**
* Props to be passed to the `Delete Column` header action item.
*/
deleteActionProps?: HeaderActionProps;
/**
* Props to be passed to the `Delete Column` header action item.
*/
insertActionProps?: HeaderActionProps;
};
}
// TODO: Get rid of Data Browser related code from here. This component should
// be generic and not depend on Data Browser related data types and logic.
export default function DataGridHeader<T extends object>({
className,
onRemoveColumn,
onEditColumn,
onInsertColumn,
componentsProps,
...props
}: DataGridHeaderProps<T>) {
const { flatHeaders, allowSort, allowResize } = useDataGridConfig<T>();
return (
<div
className={twMerge(
'sticky top-0 z-30 inline-flex w-full items-center pr-5',
className,
)}
{...props}
>
{flatHeaders.map((column: DataBrowserGridColumn<T>) => {
const headerProps = column.getHeaderProps({
style: { display: 'inline-grid' },
});
return (
<Dropdown.Root
sx={{
backgroundColor: (theme) =>
column.isDisabled
? theme.palette.background.default
: theme.palette.background.paper,
color: 'text.primary',
borderColor: 'grey.300',
}}
className={twMerge(
'group relative inline-flex self-stretch overflow-hidden font-display text-xs font-bold focus:outline-none focus-visible:outline-none',
'border-b-1 border-r-1',
column.id === 'selection' && 'sticky left-0 max-w-2',
)}
style={{
...headerProps.style,
maxWidth:
column.id === 'selection' ? 32 : headerProps.style?.maxWidth,
width:
column.id === 'selection' ? '100%' : headerProps.style?.width,
zIndex:
column.id === 'selection' ? 10 : headerProps.style?.zIndex,
position: null,
}}
key={column.id}
>
{column.id === 'selection' ? (
<span
{...headerProps}
className="relative grid w-full grid-flow-col items-center justify-between p-2"
>
{column.render('Header')}
</span>
) : (
<Dropdown.Trigger
className={twMerge(
'focus:outline-none motion-safe:transition-colors',
)}
disabled={
column.isDisabled || (column.disableSortBy && !onRemoveColumn)
}
hideChevron
>
<span
{...headerProps}
className="relative grid w-full grid-flow-col items-center justify-between p-2"
>
{column.render('Header')}
{allowSort && (
<Box component="span" sx={{ color: 'text.primary' }}>
{column.isSorted && !column.isSortedDesc && (
<ArrowUpIcon className="h-3 w-3" />
)}
{column.isSorted && column.isSortedDesc && (
<ArrowDownIcon className="h-3 w-3" />
)}
</Box>
)}
</span>
{allowResize && !column.disableResizing && (
<span
{...column.getResizerProps({
onClick: (event: Event) => event.stopPropagation(),
})}
className="absolute -right-0.5 bottom-0 top-0 z-10 h-full w-1.5 group-hover:bg-slate-900 group-hover:bg-opacity-20 group-active:bg-slate-900 group-active:bg-opacity-20 motion-safe:transition-colors"
/>
)}
</Dropdown.Trigger>
)}
<Dropdown.Content
menu
PaperProps={{ className: 'w-52 mt-1' }}
className="p-0"
>
{onEditColumn && (
<Dropdown.Item
onClick={() => onEditColumn(column)}
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
disabled={componentsProps?.editActionProps?.disabled}
>
<PencilIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>Edit Column</span>
</Dropdown.Item>
)}
{onEditColumn && <Divider component="li" sx={{ margin: 0 }} />}
{!column.disableSortBy && (
<Dropdown.Item
onClick={() => column.toggleSortBy(false)}
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
>
<ArrowUpIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>Sort Ascending</span>
</Dropdown.Item>
)}
{!column.disableSortBy && (
<Dropdown.Item
onClick={() => column.toggleSortBy(true)}
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
>
<ArrowDownIcon
className="h-4 w-4"
sx={{ color: 'text.secondary' }}
/>
<span>Sort Descending</span>
</Dropdown.Item>
)}
{onRemoveColumn && !column.isPrimary && (
<Divider component="li" className="my-1" />
)}
{onRemoveColumn && !column.isPrimary && (
<Dropdown.Item
onClick={() => onRemoveColumn(column)}
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
disabled={componentsProps?.deleteActionProps?.disabled}
sx={{ color: 'error.main' }}
>
<TrashIcon className="h-4 w-4" sx={{ color: 'error.main' }} />
<span>Delete Column</span>
</Dropdown.Item>
)}
</Dropdown.Content>
</Dropdown.Root>
);
})}
{onInsertColumn && (
<Box className="group relative inline-flex w-25 self-stretch overflow-hidden border-b-1 border-r-1 font-display text-xs font-bold focus:outline-none focus-visible:outline-none">
<Button
onClick={onInsertColumn}
variant="borderless"
color="secondary"
className="h-full w-full rounded-none text-xs hover:shadow-none focus:shadow-none focus:outline-none"
aria-label="Insert New Column"
disabled={componentsProps?.insertActionProps?.disabled}
>
<PlusIcon className="h-4 w-4" sx={{ color: 'text.disabled' }} />
</Button>
</Box>
)}
</div>
);
}

View File

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

View File

@@ -0,0 +1,110 @@
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
import { Input, inputClasses } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import type { ChangeEvent, KeyboardEvent } from 'react';
export type DataGridNumericCellProps<TData extends object> =
CommonDataGridCellProps<TData, number>;
export default function DataGridNumericCell<TData extends object>({
onSave,
optimisticValue,
temporaryValue,
onTemporaryValueChange,
}: DataGridNumericCellProps<TData>) {
const { inputRef, focusCell, isEditing, cancelEditCell } =
useDataGridCell<HTMLInputElement>();
async function handleSave() {
if (onSave) {
if (typeof temporaryValue === 'number') {
await onSave(temporaryValue);
} else {
await onSave(null);
}
}
}
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'Backspace'
) {
event.stopPropagation();
}
if (event.key === 'Tab') {
await handleSave();
}
if (event.key === 'Enter') {
await handleSave();
await focusCell();
cancelEditCell();
}
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
if (onTemporaryValueChange) {
if (event.target.value) {
onTemporaryValueChange(parseInt(event.target.value, 10));
} else {
onTemporaryValueChange(null);
}
}
}
if (isEditing) {
return (
<Input
type="number"
ref={inputRef}
value={
temporaryValue !== null && typeof temporaryValue !== 'undefined'
? temporaryValue
: ''
}
onKeyDown={handleKeyDown}
onChange={handleChange}
fullWidth
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
sx={{
[`&.${inputClasses.focused}`]: {
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
borderColor: 'transparent !important',
borderRadius: 0,
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? `${theme.palette.secondary[100]} !important`
: `${theme.palette.common.white} !important`,
},
[`& .${inputClasses.input}`]: {
backgroundColor: 'transparent',
},
}}
slotProps={{
inputWrapper: { className: 'h-full' },
input: { className: 'h-full' },
inputRoot: {
className:
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
},
}}
/>
);
}
if (optimisticValue === null || typeof optimisticValue === 'undefined') {
return (
<Text className="truncate !text-xs" color="disabled">
null
</Text>
);
}
return <Text className="truncate !text-xs">{optimisticValue}</Text>;
}

View File

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

View File

@@ -0,0 +1,91 @@
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import type { IconButtonProps } from '@/components/ui/v2/IconButton';
import { IconButton } from '@/components/ui/v2/IconButton';
import { ChevronLeftIcon } from '@/components/ui/v2/icons/ChevronLeftIcon';
import { ChevronRightIcon } from '@/components/ui/v2/icons/ChevronRightIcon';
import { Text } from '@/components/ui/v2/Text';
import clsx from 'clsx';
export interface DataGridPaginationProps extends BoxProps {
/**
* Number of pages.
*/
totalPages: number;
/**
* Current page.
*/
currentPage: number;
/**
* Function to be called when navigating to the previous page.
*/
onOpenPrevPage: VoidFunction;
/**
* Function to be called when navigating to the next page.
*/
onOpenNextPage: VoidFunction;
/**
* Props to be passed to the next button component.
*/
nextButtonProps?: IconButtonProps;
/**
* Props to be passed to the previous button component.
*/
prevButtonProps?: IconButtonProps;
}
export default function DataGridPagination({
className,
totalPages,
currentPage,
onOpenPrevPage,
onOpenNextPage,
nextButtonProps,
prevButtonProps,
...props
}: DataGridPaginationProps) {
return (
<Box
className={clsx(
'grid grid-flow-col items-center justify-around rounded-md border-1',
className,
)}
{...props}
>
<IconButton
variant="borderless"
color="secondary"
disabled={currentPage === 1}
onClick={onOpenPrevPage}
aria-label="Previous page"
{...prevButtonProps}
>
<ChevronLeftIcon className="h-4 w-4" />
</IconButton>
<span
className={clsx(
'mx-1 inline-block font-display font-medium',
currentPage > 99 ? 'text-xs' : 'text-sm+',
)}
>
{currentPage}
<Text component="span" className="mx-1 inline-block" color="disabled">
/
</Text>
{totalPages}
</span>
<IconButton
variant="borderless"
color="secondary"
disabled={currentPage === totalPages}
onClick={onOpenNextPage}
aria-label="Next page"
{...nextButtonProps}
>
<ChevronRightIcon className="h-4 w-4" />
</IconButton>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,410 @@
import { Modal } from '@/components/ui/v1/Modal';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Box } from '@/components/ui/v2/Box';
import { IconButton } from '@/components/ui/v2/IconButton';
import { AudioPreviewIcon } from '@/components/ui/v2/icons/AudioPreviewIcon';
import { FilePreviewIcon } from '@/components/ui/v2/icons/FilePreviewIcon';
import { PDFPreviewIcon } from '@/components/ui/v2/icons/PDFPreviewIcon';
import { VideoPreviewIcon } from '@/components/ui/v2/icons/VideoPreviewIcon';
import { XIcon } from '@/components/ui/v2/icons/XIcon';
import { useAppClient } from '@/features/projects/common/hooks/useAppClient';
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import clsx from 'clsx';
import type { ReactNode } from 'react';
import { useEffect, useReducer, useState } from 'react';
import type { CellProps } from 'react-table';
export type PreviewProps = {
fetchBlob: (
init?: RequestInit,
size?: { width?: number; height?: number },
) => Promise<Blob | null>;
mimeType?: string;
alt?: string;
blob?: Blob;
id?: string;
};
export type DataGridPreviewCellProps<TData extends object> = CellProps<
TData,
PreviewProps
> & {
/**
* Preview to use when the file is not an image or blob can't be fetched
* properly.
*
* @default null
*/
fallbackPreview?: ReactNode;
};
function useBlob({
fetchBlob,
blob,
mimeType,
}: Pick<PreviewProps, 'fetchBlob' | 'blob' | 'mimeType'>) {
const [objectUrl, setObjectUrl] = useState<string>();
const [error, setError] = useState<Error>();
const [loading, setLoading] = useState<boolean>(false);
// This side-effect fetches the blob of the file from the server and sets the
// relevant `objectUrl` state. Abort controller is reponsible for cancelling
// the fetch if the component is unmounted.
useEffect(() => {
const abortController = new AbortController();
async function generateOptimizedObjectUrl() {
// todo: it could be more declarative if this function was called with the
// actual preview URL here, not pre-generated in useFiles
const fetchedBlob = await fetchBlob(
{ signal: abortController.signal },
mimeType !== 'image/svg+xml' && { width: 80, height: 40 },
);
if (fetchedBlob) {
return URL.createObjectURL(fetchedBlob);
}
return undefined;
}
async function generateObjectUrl() {
setLoading(false);
setError(undefined);
if (objectUrl || (mimeType && !mimeType?.startsWith('image'))) {
return;
}
if (blob) {
setObjectUrl(URL.createObjectURL(blob));
return;
}
try {
setLoading(true);
const generatedObjectUrl = await generateOptimizedObjectUrl();
if (!abortController.signal.aborted) {
setObjectUrl(generatedObjectUrl);
}
} catch (generateError) {
if (!abortController.signal.aborted) {
setError(generateError);
}
}
if (!abortController.signal.aborted) {
setLoading(false);
}
}
generateObjectUrl();
return () => abortController.abort();
}, [blob, fetchBlob, objectUrl, mimeType]);
return { objectUrl, error, loading };
}
const previewableImages = [
'image/jpeg',
'image/png',
'image/svg+xml',
'image/webp',
];
const previewableVideos = [
'video/mp4',
'video/x-m4v',
'video/3gpp',
'video/3gpp2',
];
const previewableFileTypes = [
...previewableImages,
...previewableVideos,
'audio/',
'application/json',
];
function previewReducer(
state: { loading: boolean; error?: Error; data?: string },
action:
| { type: 'PREVIEW_LOADING' }
| { type: 'CLEAR_PREVIEW' }
| { type: 'PREVIEW_FETCHED'; payload: string }
| { type: 'PREVIEW_ERROR'; payload: Error },
): { loading: boolean; error?: Error; data?: string } {
switch (action.type) {
case 'PREVIEW_LOADING':
return { ...state, loading: true, error: undefined, data: undefined };
case 'PREVIEW_FETCHED':
return {
...state,
loading: false,
error: undefined,
data: action.payload,
};
case 'PREVIEW_ERROR':
return {
...state,
loading: false,
error: action.payload,
data: undefined,
};
case 'CLEAR_PREVIEW':
return { ...state, loading: false, error: undefined, data: undefined };
default:
return { ...state };
}
}
export default function DataGridPreviewCell<TData extends object>({
value: { fetchBlob, id, mimeType, alt, blob },
fallbackPreview = null,
}: DataGridPreviewCellProps<TData>) {
const { currentProject } = useCurrentWorkspaceAndProject();
const appClient = useAppClient();
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
const [showModal, setShowModal] = useState(false);
const [
{ loading: previewLoading, error: previewError, data: previewUrl },
dispatch,
] = useReducer(previewReducer, {
loading: false,
error: undefined,
data: undefined,
});
const isPreviewable = previewableFileTypes.some(
(type) => mimeType?.startsWith(type) || mimeType === type,
);
const isVideo = mimeType?.startsWith('video');
const isAudio = mimeType?.startsWith('audio');
const isImage = mimeType?.startsWith('image');
const isJson = mimeType === 'application/json';
async function handleOpenPreview() {
if (!mimeType) {
dispatch({
type: 'PREVIEW_ERROR',
payload: new Error('mimeType is not defined.'),
});
return;
}
if (isPreviewable) {
setShowModal(true);
dispatch({ type: 'PREVIEW_LOADING' });
}
const { presignedUrl } = await appClient.storage
.setAdminSecret(currentProject?.config?.hasura.adminSecret)
.getPresignedUrl({ fileId: id });
if (!presignedUrl) {
dispatch({
type: 'PREVIEW_ERROR',
payload: new Error('Presigned URL could not be fetched.'),
});
return;
}
if (!isPreviewable) {
window.open(presignedUrl.url, '_blank', 'noopener noreferrer');
return;
}
dispatch({ type: 'PREVIEW_FETCHED', payload: presignedUrl.url });
}
if (loading) {
return <ActivityIndicator delay={500} className="mx-auto" />;
}
if (error) {
return (
<Box
className="grid w-full grid-flow-col items-center justify-center gap-1 text-center"
sx={{ color: 'error.main' }}
>
<FilePreviewIcon error /> Error
</Box>
);
}
return (
<>
<Modal
wrapperClassName="items-center"
showModal={showModal}
close={() => setShowModal(false)}
afterLeave={() => dispatch({ type: 'CLEAR_PREVIEW' })}
className={clsx(
previewableImages.includes(mimeType) || isVideo || isAudio
? 'mx-12 flex h-screen items-center justify-center'
: 'mt-4 inline-block h-near-screen w-full px-12',
)}
>
<Box
className={clsx(
!isJson && 'bg-checker-pattern',
'relative mx-auto flex overflow-hidden rounded-md',
)}
sx={{
backgroundColor: isJson && 'background.default',
color: 'text.primary',
}}
>
{!previewLoading && (
<IconButton
aria-label="Close"
variant="borderless"
color="secondary"
className="absolute right-2 top-2 z-50 p-2"
sx={{
[`&:hover, &:active, &:focus`]: {
backgroundColor: (theme) => {
if (isAudio || isVideo || isJson) {
return 'common.black';
}
return theme.palette.mode === 'dark'
? 'grey.800'
: 'grey.200';
},
},
}}
onClick={() => setShowModal(false)}
>
<XIcon
className="h-5 w-5"
sx={{
color: (theme) => {
if (isAudio || isVideo || isJson) {
return 'common.white';
}
return theme.palette.mode === 'dark'
? 'grey.100'
: 'grey.700';
},
}}
/>
</IconButton>
)}
{previewLoading && !previewUrl && (
<ActivityIndicator
delay={500}
className="mx-auto"
label="Loading preview..."
/>
)}
{previewError && (
<Box
className="px-6 py-3.5 pr-12 text-start font-medium"
sx={{ color: 'error.main' }}
>
<p>Error: Preview can&apos;t be loaded.</p>
<p>{previewError.message}</p>
</Box>
)}
{previewUrl && isImage && (
<picture className="h-auto max-h-near-screen min-h-38 min-w-38">
<source srcSet={previewUrl} type={mimeType} />
<img
src={previewUrl}
alt={alt}
className="h-full w-full object-scale-down"
/>
</picture>
)}
{previewUrl && isVideo && (
<video
autoPlay
controls
className="h-auto max-h-near-screen w-full bg-black"
>
<track kind="captions" />
<source src={previewUrl} type={mimeType} />
Your browser does not support the video tag.
</video>
)}
{previewUrl && isAudio && (
<audio autoPlay controls className="h-28 bg-black">
<track kind="captions" />
<source src={previewUrl} type={mimeType} />
Your browser does not support the audio tag.
</audio>
)}
{!previewLoading &&
previewUrl &&
!previewableImages.includes(mimeType) &&
!isVideo &&
!isAudio && (
<iframe
src={previewUrl}
className="h-near-screen w-full"
title="File preview"
/>
)}
</Box>
</Modal>
<div className="flex h-full w-full justify-center">
{previewableImages.includes(mimeType) && objectUrl && (
<button
type="button"
aria-label={alt}
onClick={handleOpenPreview}
className="mx-auto h-full"
>
<picture className="h-full w-20">
<source srcSet={objectUrl} type={mimeType} />
<img
src={objectUrl}
alt={alt}
className="h-full w-full object-scale-down"
/>
</picture>
</button>
)}
{(!previewableImages.includes(mimeType) || !objectUrl) && (
<button
type="button"
onClick={handleOpenPreview}
aria-label={alt}
className="grid h-full w-full items-center justify-center self-center"
>
{isVideo && <VideoPreviewIcon className="h-5 w-5" />}
{isAudio && <AudioPreviewIcon className="h-5 w-5" />}
{mimeType === 'application/pdf' && (
<PDFPreviewIcon className="h-5 w-5" />
)}
{!isVideo &&
!isAudio &&
mimeType !== 'application/pdf' &&
fallbackPreview}
</button>
)}
</div>
</>
);
}

View File

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

View File

@@ -0,0 +1,243 @@
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
import { Button } from '@/components/ui/v2/Button';
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
import { Input, inputClasses } from '@/components/ui/v2/Input';
import { Text } from '@/components/ui/v2/Text';
import { copy } from '@/utils/copy';
import type { ChangeEvent, KeyboardEvent, Ref } from 'react';
import { useEffect } from 'react';
export type DataGridTextCellProps<TData extends object> =
CommonDataGridCellProps<TData, string>;
export default function DataGridTextCell<TData extends object>({
onSave,
optimisticValue,
temporaryValue,
onTemporaryValueChange,
cell: {
column: { isCopiable, specificType },
},
}: DataGridTextCellProps<TData>) {
const isMultiline =
specificType === 'text' ||
specificType === 'bpchar' ||
specificType === 'varchar' ||
specificType === 'json' ||
specificType === 'jsonb';
const normalizedOptimisticValue =
optimisticValue !== null && typeof optimisticValue === 'object'
? optimisticValue
: (String(optimisticValue) || '').replace(/(\\n)+/gi, ' ');
const normalizedTemporaryValue =
temporaryValue !== null && typeof temporaryValue === 'object'
? JSON.stringify(temporaryValue)
: temporaryValue;
const { inputRef, focusCell, isEditing, cancelEditCell } = useDataGridCell<
HTMLInputElement | HTMLTextAreaElement
>();
useEffect(() => {
if (isEditing && isMultiline) {
const textArea = inputRef.current as HTMLTextAreaElement;
textArea.setSelectionRange(textArea.value.length, textArea.value.length);
}
}, [inputRef, isEditing, isMultiline]);
async function handleSave() {
if (onSave) {
await onSave((normalizedTemporaryValue || '').replace(/\n/gi, `\\n`));
}
}
async function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'Backspace'
) {
event.stopPropagation();
}
if (event.key === 'Tab') {
await handleSave();
}
if (event.key === 'Enter') {
await handleSave();
await focusCell();
cancelEditCell();
}
}
async function handleTextAreaKeyDown(
event: KeyboardEvent<HTMLTextAreaElement>,
) {
if (
event.key === 'ArrowLeft' ||
event.key === 'ArrowRight' ||
event.key === 'ArrowUp' ||
event.key === 'ArrowDown' ||
event.key === 'Backspace'
) {
event.stopPropagation();
}
// Saving content Enter / CTRL + Enter / CMD + Enter (macOS) - but not on
// Shift + Enter
if (
(!event.shiftKey && event.key === 'Enter') ||
(event.ctrlKey && event.key === 'Enter') ||
(event.metaKey && event.key === 'Enter')
) {
event.preventDefault();
event.stopPropagation();
await handleSave();
await focusCell();
cancelEditCell();
}
if (event.key === 'Tab') {
await handleSave();
}
}
function handleChange(
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
) {
if (onTemporaryValueChange) {
onTemporaryValueChange(event.target.value);
}
}
if (isEditing && isMultiline) {
return (
<Input
multiline
ref={inputRef as Ref<HTMLInputElement>}
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
onChange={handleChange}
onKeyDown={handleTextAreaKeyDown}
fullWidth
className="absolute top-0 z-10 -mx-0.5 h-full min-h-38"
rows={5}
sx={{
[`&.${inputClasses.focused}`]: {
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
borderColor: 'transparent !important',
borderRadius: 0,
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? `${theme.palette.secondary[100]} !important`
: `${theme.palette.common.white} !important`,
},
[`& .${inputClasses.input}`]: {
backgroundColor: 'transparent',
},
}}
slotProps={{
inputRoot: {
className:
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
},
}}
/>
);
}
if (isEditing) {
return (
<Input
ref={inputRef as Ref<HTMLInputElement>}
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
onChange={handleChange}
onKeyDown={handleInputKeyDown}
fullWidth
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
sx={{
[`&.${inputClasses.focused}`]: {
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
borderColor: 'transparent !important',
borderRadius: 0,
backgroundColor: (theme) =>
theme.palette.mode === 'dark'
? `${theme.palette.secondary[100]} !important`
: `${theme.palette.common.white} !important`,
},
[`& .${inputClasses.input}`]: {
backgroundColor: 'transparent',
},
}}
slotProps={{
inputWrapper: { className: 'h-full' },
input: { className: 'h-full' },
inputRoot: {
className:
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
},
}}
/>
);
}
if (!optimisticValue) {
return (
<Text className="truncate !text-xs" color="secondary">
{optimisticValue === '' ? 'empty' : 'null'}
</Text>
);
}
if (isCopiable) {
return (
<div className="grid grid-flow-col items-center justify-start gap-1">
<Button
variant="borderless"
color="secondary"
onClick={(event) => {
event.stopPropagation();
const copiableValue =
typeof optimisticValue === 'object'
? JSON.stringify(optimisticValue)
: String(optimisticValue).replace(/\\n/gi, '\n');
copy(copiableValue, 'Value');
}}
className="-ml-px min-w-0 p-0"
aria-label="Copy value"
sx={{
color: (theme) =>
theme.palette.mode === 'dark'
? 'text.secondary'
: 'text.disabled',
}}
>
<CopyIcon className="h-4 w-4" />
</Button>
<Text className="truncate text-xs">
{typeof normalizedOptimisticValue === 'object'
? JSON.stringify(normalizedOptimisticValue)
: normalizedOptimisticValue}
</Text>
</div>
);
}
return (
<Text className="truncate text-xs">
{typeof normalizedOptimisticValue === 'object'
? JSON.stringify(normalizedOptimisticValue)
: normalizedOptimisticValue}
</Text>
);
}

View File

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

View File

@@ -8,7 +8,7 @@ import type { ForwardedRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { FieldValues, UseControllerProps } from 'react-hook-form'; import type { FieldValues, UseControllerProps } from 'react-hook-form';
import { useController, useFormContext } 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< export interface ControlledAutocompleteProps<
TOption extends AutocompleteOption = AutocompleteOption, TOption extends AutocompleteOption = AutocompleteOption,

View File

@@ -5,7 +5,7 @@ import type { ForwardedRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { FieldValues, UseControllerProps } from 'react-hook-form'; import type { FieldValues, UseControllerProps } from 'react-hook-form';
import { useController, useFormContext } 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> export interface ControlledCheckboxProps<TFieldValues extends FieldValues = any>
extends CheckboxProps { extends CheckboxProps {
@@ -38,7 +38,7 @@ function ControlledCheckbox(
uncheckWhenDisabled, uncheckWhenDisabled,
...props ...props
}: ControlledCheckboxProps, }: ControlledCheckboxProps,
ref: ForwardedRef<HTMLButtonElement>, ref: ForwardedRef<HTMLInputElement>,
) { ) {
const { setValue } = useFormContext(); const { setValue } = useFormContext();
const { field } = useController({ const { field } = useController({

View File

@@ -4,7 +4,7 @@ import type { ForwardedRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import type { FieldValues, UseControllerProps } from 'react-hook-form'; import type { FieldValues, UseControllerProps } from 'react-hook-form';
import { useController, useFormContext } 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> export interface ControlledSelectProps<TFieldValues extends FieldValues = any>
extends SelectProps<TFieldValues> { extends SelectProps<TFieldValues> {
@@ -24,7 +24,7 @@ export interface ControlledSelectProps<TFieldValues extends FieldValues = any>
function ControlledSelect( function ControlledSelect(
{ controllerProps, name, control, ...props }: ControlledSelectProps, { controllerProps, name, control, ...props }: ControlledSelectProps,
ref: ForwardedRef<HTMLButtonElement>, ref: ForwardedRef<HTMLInputElement>,
) { ) {
const { setValue } = useFormContext(); const { setValue } = useFormContext();
const { field } = useController({ const { field } = useController({

View File

@@ -2,13 +2,13 @@ import type { SwitchProps } from '@/components/ui/v2/Switch';
import { Switch } from '@/components/ui/v2/Switch'; import { Switch } from '@/components/ui/v2/Switch';
import type { ForwardedRef } from 'react'; import type { ForwardedRef } from 'react';
import { forwardRef } from 'react'; import { forwardRef } from 'react';
import { useController, useFormContext } from 'react-hook-form';
import type { import type {
ControllerProps, ControllerProps,
FieldValues, FieldValues,
UseControllerProps, UseControllerProps,
} from 'react-hook-form'; } from 'react-hook-form/dist/types';
import { useController, useFormContext } from 'react-hook-form'; import mergeRefs from 'react-merge-refs';
import { mergeRefs } from 'react-merge-refs';
export interface ControlledSwitchProps<TFieldValues extends FieldValues = any> export interface ControlledSwitchProps<TFieldValues extends FieldValues = any>
extends SwitchProps { extends SwitchProps {

View File

@@ -1,59 +0,0 @@
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;

View File

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

View File

@@ -0,0 +1,46 @@
import { AISidebar } from '@/components/layout/AISidebar';
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
import { ProjectLayout } from '@/components/layout/ProjectLayout';
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
import { Box } from '@/components/ui/v2/Box';
import { twMerge } from 'tailwind-merge';
export interface AILayoutProps extends ProjectLayoutProps {
/**
* Props passed to the sidebar component.
*/
sidebarProps?: SettingsSidebarProps;
}
export default function AILayout({
children,
mainContainerProps: {
className: mainContainerClassName,
...mainContainerProps
} = {},
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
...props
}: AILayoutProps) {
return (
<ProjectLayout
mainContainerProps={{
className: twMerge('flex h-full', mainContainerClassName),
...mainContainerProps,
}}
{...props}
>
<AISidebar
className={twMerge('w-full max-w-sidebar', sidebarClassName)}
{...sidebarProps}
/>
<Box
sx={{ backgroundColor: 'background.default' }}
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
>
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
</Box>
</ProjectLayout>
);
}

View File

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

View File

@@ -0,0 +1,143 @@
import { NavLink } from '@/components/common/NavLink';
import { Backdrop } from '@/components/ui/v2/Backdrop';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { IconButton } from '@/components/ui/v2/IconButton';
import { List } from '@/components/ui/v2/List';
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
import { ListItem } from '@/components/ui/v2/ListItem';
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { twMerge } from 'tailwind-merge';
export interface AISidebarProps extends Omit<BoxProps, 'children'> {}
interface AINavLinkProps extends ListItemButtonProps {
/**
* Link to navigate to.
*/
href: string;
/**
* Determines whether or not the link should be active if href matches the current route.
*
* @default true
*/
exact?: boolean;
}
function AINavLink({ exact = true, href, children, ...props }: AINavLinkProps) {
const router = useRouter();
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}/ai`;
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
const active = exact
? router.asPath === finalUrl
: router.asPath.startsWith(finalUrl);
return (
<ListItem.Root>
<ListItem.Button
dense
href={finalUrl}
component={NavLink}
selected={active}
{...props}
>
<ListItem.Text>{children}</ListItem.Text>
</ListItem.Button>
</ListItem.Root>
);
}
export default function AISidebar({ className, ...props }: AISidebarProps) {
const [expanded, setExpanded] = useState(false);
const { currentProject } = useCurrentWorkspaceAndProject();
function toggleExpanded() {
setExpanded(!expanded);
}
function handleSelect() {
setExpanded(false);
}
function closeSidebarWhenEscapeIsPressed(event: KeyboardEvent) {
if (event.key === 'Escape') {
setExpanded(false);
}
}
useEffect(() => {
if (typeof document !== 'undefined') {
document.addEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}
return () =>
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
}, []);
if (!currentProject) {
return null;
}
return (
<>
<Backdrop
open={expanded}
className="absolute bottom-0 left-0 right-0 top-0 z-[34] md:hidden"
role="button"
tabIndex={-1}
onClick={() => setExpanded(false)}
aria-label="Close sidebar overlay"
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
setExpanded(false);
}}
/>
<Box
component="aside"
className={twMerge(
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
className,
)}
{...props}
>
<nav aria-label="Settings navigation">
<List className="grid gap-2">
<AINavLink
href="/auto-embeddings"
exact={false}
onClick={handleSelect}
>
Auto-Embeddings
</AINavLink>
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
Assistants
</AINavLink>
</List>
</nav>
</Box>
<IconButton
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
onClick={toggleExpanded}
aria-label="Toggle sidebar"
>
<Image
width={16}
height={16}
src="/assets/table.svg"
alt="A monochrome table"
/>
</IconButton>
</>
);
}

View File

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

View File

@@ -6,24 +6,19 @@ import { Button } from '@/components/ui/v2/Button';
import { Divider } from '@/components/ui/v2/Divider'; import { Divider } from '@/components/ui/v2/Divider';
import { Dropdown, useDropdown } from '@/components/ui/v2/Dropdown'; import { Dropdown, useDropdown } from '@/components/ui/v2/Dropdown';
import { Text } from '@/components/ui/v2/Text'; import { Text } from '@/components/ui/v2/Text';
import { useUserData } from '@/hooks/useUserData';
import { useAuth } from '@/providers/Auth';
import { useApolloClient } from '@apollo/client'; import { useApolloClient } from '@apollo/client';
import { useSignOut, useUserData } from '@nhost/nextjs';
import getConfig from 'next/config'; import getConfig from 'next/config';
import { useRouter } from 'next/router';
function AccountMenuContent() { function AccountMenuContent() {
const user = useUserData(); const user = useUserData();
const { signout } = useAuth(); const { signOut } = useSignOut();
const router = useRouter();
const apolloClient = useApolloClient(); const apolloClient = useApolloClient();
const { handleClose } = useDropdown(); const { handleClose } = useDropdown();
const { publicRuntimeConfig } = getConfig(); const { publicRuntimeConfig } = getConfig();
async function handleSignOut() {
handleClose();
await apolloClient.clearStore();
await signout();
}
return ( return (
<Box className="grid grid-flow-row"> <Box className="grid grid-flow-row">
<Box className="grid grid-flow-col items-center justify-start gap-3 p-4"> <Box className="grid grid-flow-col items-center justify-start gap-3 p-4">
@@ -75,7 +70,12 @@ function AccountMenuContent() {
color="error" color="error"
variant="borderless" variant="borderless"
className="w-full justify-start" className="w-full justify-start"
onClick={handleSignOut} onClick={async () => {
handleClose();
await apolloClient.clearStore();
await signOut();
await router.push('/signin');
}}
> >
Sign out Sign out
</Button> </Button>

View File

@@ -1,24 +1,24 @@
import { InviteNotification } from '@/components/common/InviteNotification';
import type { BaseLayoutProps } from '@/components/layout/BaseLayout'; import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
import { BaseLayout } from '@/components/layout/BaseLayout'; import { BaseLayout } from '@/components/layout/BaseLayout';
import { Container } from '@/components/layout/Container'; import { Container } from '@/components/layout/Container';
import { Header } from '@/components/layout/Header'; 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 { HighlightedText } from '@/components/presentational/HighlightedText';
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator'; import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
import { Link } from '@/components/ui/v2/Link'; import { Link } from '@/components/ui/v2/Link';
import { Text } from '@/components/ui/v2/Text'; import { Text } from '@/components/ui/v2/Text';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform'; import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
import { useAuthenticationStatus } from '@nhost/nextjs';
import Analytics from '@/components/analytics/analytics';
import { useMediaQuery } from '@/components/common/useMediaQuery'; import { useMediaQuery } from '@/components/common/useMediaQuery';
import { MainNav } from '@/components/layout/MainNav';
import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav'; 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 { OrgStatus } from '@/features/orgs/components/OrgStatus';
import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy'; import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy';
import { useNotFoundRedirect } from '@/features/orgs/projects/common/hooks/useNotFoundRedirect'; import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoundRedirect';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useAuth } from '@/providers/Auth';
import Image from 'next/image'; import Image from 'next/image';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -33,7 +33,7 @@ export default function AuthenticatedLayout({
const isPlatform = useIsPlatform(); const isPlatform = useIsPlatform();
const isMdOrLarger = useMediaQuery('md'); const isMdOrLarger = useMediaQuery('md');
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuthenticationStatus();
const isHealthy = useIsHealthy(); const isHealthy = useIsHealthy();
const [mainNavContainer, setMainNavContainer] = useState(null); const [mainNavContainer, setMainNavContainer] = useState(null);
const { mainNavPinned } = useTreeNavState(); const { mainNavPinned } = useTreeNavState();
@@ -44,6 +44,7 @@ export default function AuthenticatedLayout({
if (!isPlatform || isLoading || isAuthenticated) { if (!isPlatform || isLoading || isAuthenticated) {
return; return;
} }
router.push('/signin'); router.push('/signin');
}, [isLoading, isAuthenticated, router, isPlatform]); }, [isLoading, isAuthenticated, router, isPlatform]);
@@ -65,15 +66,11 @@ export default function AuthenticatedLayout({
if (isPlatform && isLoading) { if (isPlatform && isLoading) {
return ( return (
<BaseLayout className="h-full" {...props}> <BaseLayout className="h-full" {...props}>
<Header className="flex max-h-[59px] flex-auto py-1" /> <Header className="flex max-h-[59px] flex-auto" />
</BaseLayout> </BaseLayout>
); );
} }
// if (isPlatform && !isLoading && !isAuthenticated) {
// return null;
// }
if (!isPlatform && !isHealthy) { if (!isPlatform && !isHealthy) {
return ( return (
<BaseLayout className="h-full" {...props}> <BaseLayout className="h-full" {...props}>
@@ -101,7 +98,7 @@ export default function AuthenticatedLayout({
<HighlightedText className="font-mono">nhost up</HighlightedText>? <HighlightedText className="font-mono">nhost up</HighlightedText>?
Please refer to the{' '} Please refer to the{' '}
<Link <Link
href="https://docs.nhost.io/platform/cli/local-development" href="https://docs.nhost.io/platform/cli"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
underline="hover" underline="hover"
@@ -146,10 +143,11 @@ export default function AuthenticatedLayout({
> >
<div className="flex h-full w-full flex-col overflow-auto"> <div className="flex h-full w-full flex-col overflow-auto">
<OrgStatus /> <OrgStatus />
<Analytics />
{children} {children}
</div> </div>
</RetryableErrorBoundary> </RetryableErrorBoundary>
<InviteNotification />
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>

View File

@@ -0,0 +1,78 @@
import { NavLink } from '@/components/common/NavLink';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { Text } from '@/components/ui/v2/Text';
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
import { twMerge } from 'tailwind-merge';
export interface BreadcrumbsProps extends BoxProps {}
export default function Breadcrumbs({ className, ...props }: BreadcrumbsProps) {
const isPlatform = useIsPlatform();
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
if (!isPlatform) {
return (
<Box
className={twMerge(
'grid grid-flow-col items-center gap-3 text-sm font-medium',
className,
)}
{...props}
>
<Text color="disabled">/</Text>
<Text className="truncate text-[13px] sm:text-sm">local</Text>
<Text color="disabled">/</Text>
<NavLink
href="/local/local"
className="truncate text-[13px] hover:underline sm:text-sm"
sx={{ color: 'text.primary' }}
>
local
</NavLink>
</Box>
);
}
return (
<Box
className={twMerge(
'grid grid-flow-col items-center gap-3 text-sm font-medium',
className,
)}
{...props}
>
{currentWorkspace && (
<>
<Text color="disabled">/</Text>
<NavLink
href={`/${currentWorkspace.slug}`}
className="truncate text-[13px] hover:underline sm:text-sm"
sx={{ color: 'text.primary' }}
>
{currentWorkspace.name}
</NavLink>
</>
)}
{currentProject && (
<>
<Text color="disabled">/</Text>
<NavLink
href={`/${currentWorkspace.slug}/${currentProject.slug}`}
className="truncate text-[13px] hover:underline sm:text-sm"
sx={{ color: 'text.primary' }}
>
{currentProject.name}
</NavLink>
</>
)}
</Box>
);
}

View File

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

View File

@@ -0,0 +1,90 @@
import type { IconLinkProps } from '@/components/common/IconLink';
import { IconLink } from '@/components/common/IconLink';
import { Nav } from '@/components/presentational/Nav';
import type { BoxProps } from '@/components/ui/v2/Box';
import { Box } from '@/components/ui/v2/Box';
import { useProjectRoutes } from '@/features/projects/common/hooks/useProjectRoutes';
import { useRouter } from 'next/router';
import { twMerge } from 'tailwind-merge';
export interface DesktopNavProps extends Omit<BoxProps, 'children'> {}
interface DesktopNavLinkProps extends IconLinkProps {
/**
* Determines whether or not the link should be active if it's href exactly
* matches the current route.
*
* @default true
*/
exact?: boolean;
/**
* Path of the link.
*/
path?: string;
}
function DesktopNavLink({
exact = true,
href,
path,
...props
}: DesktopNavLinkProps) {
const router = useRouter();
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}`;
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
const finalRelativePath =
path && path !== '/' ? `${baseUrl}${path}` : baseUrl;
const active = exact
? router.asPath === finalUrl
: router.asPath.startsWith(finalRelativePath);
return (
<li>
<IconLink {...props} href={finalUrl} active={props.active || active} />
</li>
);
}
export default function DesktopNav({ className, ...props }: DesktopNavProps) {
const { allRoutes } = useProjectRoutes();
return (
<Box
className={twMerge(
'w-20 content-start overflow-hidden overflow-y-auto border-r-1 px-1 pb-10',
className,
)}
{...props}
>
<Nav
aria-label="Main navigation"
className="w-full"
flow="row"
listProps={{ className: 'gap-2 justify-center py-2' }}
>
{allRoutes.map(
({
relativePath,
relativeMainPath,
label,
icon,
exact,
disabled,
}) => (
<DesktopNavLink
href={relativePath}
path={relativeMainPath || relativePath}
exact={exact}
icon={icon}
key={relativePath}
disabled={disabled}
>
{label}
</DesktopNavLink>
),
)}
</Nav>
</Box>
);
}

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