Compare commits
63 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8cd6c3a6 | ||
|
|
b89500d175 | ||
|
|
013e1c1d70 | ||
|
|
4fd176bce2 | ||
|
|
d26b6b848d | ||
|
|
3df7ca2a33 | ||
|
|
38e7e9deee | ||
|
|
25f07a3763 | ||
|
|
38c3db4a9e | ||
|
|
a1333df2a1 | ||
|
|
0420e4fda4 | ||
|
|
97f6642c43 | ||
|
|
69c1ffa766 | ||
|
|
8ea263ec75 | ||
|
|
7b9cdf1f5f | ||
|
|
1c4f321f64 | ||
|
|
60d4d28627 | ||
|
|
34fdcb8863 | ||
|
|
78436ca29e | ||
|
|
ea6584614b | ||
|
|
4937c5e055 | ||
|
|
b5a3895e16 | ||
|
|
9b24807562 | ||
|
|
15421321f4 | ||
|
|
a4790c6eac | ||
|
|
992aa997d5 | ||
|
|
382dc11aaa | ||
|
|
1976bc48a5 | ||
|
|
38696f5e88 | ||
|
|
064ea6a337 | ||
|
|
0d323e10f5 | ||
|
|
6921526cf5 | ||
|
|
ad57a9e473 | ||
|
|
b40d375039 | ||
|
|
af61cb737a | ||
|
|
f84cd550d9 | ||
|
|
1986178f7a | ||
|
|
aa9210c838 | ||
|
|
695466df95 | ||
|
|
fe23bde306 | ||
|
|
ea2dbf8734 | ||
|
|
f4167e328c | ||
|
|
56ebb1f719 | ||
|
|
2b6a4adf40 | ||
|
|
6cc8f954e1 | ||
|
|
1821df7a96 | ||
|
|
ab8a55ede4 | ||
|
|
39eb70678b | ||
|
|
e3cd5f858f | ||
|
|
69d9ab60c8 | ||
|
|
a8961c0ab0 | ||
|
|
6b8163d21f | ||
|
|
a21553c774 | ||
|
|
2dd4df5170 | ||
|
|
403a45d2cf | ||
|
|
05f063b8e2 | ||
|
|
09f5bed1e8 | ||
|
|
91d5fbba42 | ||
|
|
498363db25 | ||
|
|
8c2779930b | ||
|
|
0aa27a2fd1 | ||
|
|
8656749e5a | ||
|
|
d097eb8feb |
@@ -30,6 +30,14 @@ runs:
|
|||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
|
- shell: bash
|
||||||
|
name: Use Latest Corepack
|
||||||
|
run: |
|
||||||
|
echo "Before: corepack version => $(corepack --version || echo 'not installed')"
|
||||||
|
npm install -g corepack@latest
|
||||||
|
echo "After : corepack version => $(corepack --version)"
|
||||||
|
corepack enable
|
||||||
|
pnpm --version
|
||||||
- shell: bash
|
- shell: bash
|
||||||
name: Install packages
|
name: Install packages
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|||||||
20
.github/actions/nhost-cli/action.yaml
vendored
20
.github/actions/nhost-cli/action.yaml
vendored
@@ -1,6 +1,9 @@
|
|||||||
name: Nhost CLI
|
name: Nhost CLI
|
||||||
description: 'Action to install the Nhost CLI and to run an application'
|
description: 'Action to install the Nhost CLI and to run an application'
|
||||||
inputs:
|
inputs:
|
||||||
|
init:
|
||||||
|
description: 'Initialize the application'
|
||||||
|
default: 'false'
|
||||||
start:
|
start:
|
||||||
description: "Start the application. If false, the application won't be started"
|
description: "Start the application. If false, the application won't be started"
|
||||||
default: 'false'
|
default: 'false'
|
||||||
@@ -16,6 +19,9 @@ inputs:
|
|||||||
version:
|
version:
|
||||||
description: 'Version of the Nhost CLI'
|
description: 'Version of the Nhost CLI'
|
||||||
default: 'latest'
|
default: 'latest'
|
||||||
|
dashboard-image:
|
||||||
|
description: 'Image of the dashboard'
|
||||||
|
default: 'nhost/dashboard:latest'
|
||||||
config:
|
config:
|
||||||
description: 'Values to be injected into nhost/config.yaml'
|
description: 'Values to be injected into nhost/config.yaml'
|
||||||
|
|
||||||
@@ -40,6 +46,13 @@ runs:
|
|||||||
timeout_minutes: 3
|
timeout_minutes: 3
|
||||||
max_attempts: 10
|
max_attempts: 10
|
||||||
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
|
command: bash <(curl --silent -L https://raw.githubusercontent.com/nhost/cli/main/get.sh) ${{ inputs.version }}
|
||||||
|
- name: Initialize a new project from scratch
|
||||||
|
if: ${{ inputs.init == 'true' }}
|
||||||
|
shell: bash
|
||||||
|
working-directory: ${{ inputs.path }}
|
||||||
|
run: |
|
||||||
|
rm -rf ./*
|
||||||
|
nhost init
|
||||||
- name: Set custom configuration
|
- name: Set custom configuration
|
||||||
if: ${{ inputs.config }}
|
if: ${{ inputs.config }}
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -50,7 +63,12 @@ runs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ inputs.path }}
|
working-directory: ${{ inputs.path }}
|
||||||
run: |
|
run: |
|
||||||
cp .secrets.example .secrets
|
if [ -n "${{ inputs.dashboard-image }}" ]; then
|
||||||
|
export NHOST_DASHBOARD_VERSION=${{ inputs.dashboard-image }}
|
||||||
|
fi
|
||||||
|
if [ -f .secrets.example ]; then
|
||||||
|
cp .secrets.example .secrets
|
||||||
|
fi
|
||||||
nhost up
|
nhost up
|
||||||
- name: Log on failure
|
- name: Log on failure
|
||||||
if: steps.wait.outcome == 'failure'
|
if: steps.wait.outcome == 'failure'
|
||||||
|
|||||||
31
.github/workflows/ci.yaml
vendored
31
.github/workflows/ci.yaml
vendored
@@ -19,7 +19,6 @@ env:
|
|||||||
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 }}
|
||||||
@@ -134,10 +133,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||||
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
TURBO_TEAM: ${{ env.TURBO_TEAM }}
|
||||||
|
# * Build Dashboard image to test it locally
|
||||||
|
- name: Build Dashboard local image
|
||||||
|
if: matrix.package.path == 'dashboard'
|
||||||
|
run: |
|
||||||
|
docker build -t nhost/dashboard:0.0.0-dev -f ${{ matrix.package.path }}/Dockerfile .
|
||||||
|
mkdir -p nhost-test-project
|
||||||
# * Install Nhost CLI if a `nhost/config.yaml` file is found
|
# * Install Nhost CLI if a `nhost/config.yaml` file is found
|
||||||
- name: Install Nhost CLI
|
- name: Install Nhost CLI
|
||||||
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != ''
|
if: hashFiles(format('{0}/nhost/config.yaml', matrix.package.path)) != '' && matrix.package.path != 'dashboard'
|
||||||
uses: ./.github/actions/nhost-cli
|
uses: ./.github/actions/nhost-cli
|
||||||
|
# * Install Nhost CLI to test Dashboard locally
|
||||||
|
- name: Install Nhost CLI (Local Dashboard tests)
|
||||||
|
timeout-minutes: 5
|
||||||
|
if: matrix.package.path == 'dashboard'
|
||||||
|
uses: ./.github/actions/nhost-cli
|
||||||
|
with:
|
||||||
|
init: 'true' # Initialize the application
|
||||||
|
start: 'true' # Start the application
|
||||||
|
path: ./nhost-test-project
|
||||||
|
wait: 'true' # Wait until the application is ready
|
||||||
|
dashboard-image: 'nhost/dashboard:0.0.0-dev'
|
||||||
- name: Fetch Dashboard Preview URL
|
- name: Fetch Dashboard Preview URL
|
||||||
id: fetch-dashboard-preview-url
|
id: fetch-dashboard-preview-url
|
||||||
uses: zentered/vercel-preview-url@v1.1.9
|
uses: zentered/vercel-preview-url@v1.1.9
|
||||||
@@ -157,6 +173,17 @@ jobs:
|
|||||||
- name: Run e2e tests
|
- name: Run e2e tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
run: pnpm --filter="${{ matrix.package.name }}" run e2e
|
||||||
|
# * Run the `e2e-local` script of the dashboard
|
||||||
|
- name: Run Local Dashboard e2e tests
|
||||||
|
if: matrix.package.path == 'dashboard'
|
||||||
|
timeout-minutes: 5
|
||||||
|
run: |
|
||||||
|
pnpm --filter="${{ matrix.package.name }}" run e2e-local
|
||||||
|
|
||||||
|
- name: Stop Nhost CLI
|
||||||
|
if: matrix.package.path == 'dashboard'
|
||||||
|
working-directory: ./nhost-test-project
|
||||||
|
run: nhost down
|
||||||
- id: file-name
|
- 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
|
||||||
|
|||||||
17
.github/workflows/test-nhost-cli-action.yaml
vendored
17
.github/workflows/test-nhost-cli-action.yaml
vendored
@@ -25,10 +25,10 @@ jobs:
|
|||||||
- name: Install the Nhost CLI and start the application
|
- name: Install the Nhost CLI and start the application
|
||||||
uses: ./.github/actions/nhost-cli
|
uses: ./.github/actions/nhost-cli
|
||||||
with:
|
with:
|
||||||
path: packages/nhost-js
|
init: true
|
||||||
start: true
|
start: true
|
||||||
- name: should be running
|
- name: should be running
|
||||||
run: curl -sSf 'https://local.hasura.nhost.run' > /dev/null
|
run: curl -sSf 'https://local.hasura.local.nhost.run/' > /dev/null
|
||||||
|
|
||||||
stop:
|
stop:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
- name: Install the Nhost CLI, start and stop the application
|
- name: Install the Nhost CLI, start and stop the application
|
||||||
uses: ./.github/actions/nhost-cli
|
uses: ./.github/actions/nhost-cli
|
||||||
with:
|
with:
|
||||||
path: packages/nhost-js
|
init: true
|
||||||
start: true
|
start: true
|
||||||
stop: true
|
stop: true
|
||||||
- name: should have no live docker container
|
- name: should have no live docker container
|
||||||
@@ -55,12 +55,13 @@ jobs:
|
|||||||
- name: Install the Nhost CLI and run the application
|
- name: Install the Nhost CLI and run the application
|
||||||
uses: ./.github/actions/nhost-cli
|
uses: ./.github/actions/nhost-cli
|
||||||
with:
|
with:
|
||||||
path: packages/nhost-js
|
init: true
|
||||||
|
version: v1.29.3
|
||||||
start: true
|
start: true
|
||||||
- name: should find the injected hasura-auth version
|
- name: should find the injected hasura-auth version
|
||||||
run: |
|
run: |
|
||||||
VERSION=$(curl -sSf 'https://local.auth.nhost.run/v1/version')
|
VERSION=$(curl -sSf 'https://local.auth.local.nhost.run/v1/version')
|
||||||
EXPECTED_VERSION='{"version":"v0.20.1"}'
|
EXPECTED_VERSION='{"version":"0.36.1"}'
|
||||||
if [ "$VERSION" != "$EXPECTED_VERSION" ]; then
|
if [ "$VERSION" != "$EXPECTED_VERSION" ]; then
|
||||||
echo "Expected version $EXPECTED_VERSION but got $VERSION"
|
echo "Expected version $EXPECTED_VERSION but got $VERSION"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -73,6 +74,6 @@ jobs:
|
|||||||
- name: Install the Nhost CLI
|
- name: Install the Nhost CLI
|
||||||
uses: ./.github/actions/nhost-cli
|
uses: ./.github/actions/nhost-cli
|
||||||
with:
|
with:
|
||||||
version: v1.0.1
|
version: v1.27.2
|
||||||
- name: should find the correct version
|
- name: should find the correct version
|
||||||
run: nhost --version | head -n 1 | grep v1.0.1 || exit 1
|
run: nhost --version | head -n 1 | grep v1.27.2 || exit 1
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,3 +63,5 @@ out/
|
|||||||
# Nix
|
# Nix
|
||||||
.envrc
|
.envrc
|
||||||
.direnv/
|
.direnv/
|
||||||
|
|
||||||
|
/.vscode/
|
||||||
|
|||||||
@@ -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/get-started).
|
- Include documentation in the repo or on our [docs site](https://docs.nhost.io).
|
||||||
- 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.
|
||||||
|
|||||||
@@ -14,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/cli)
|
### [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
|
||||||
|
|
||||||
- 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/get-started/cli-workflow/install-cli) if you have not installed it yet
|
- Please refer to the [installation guide](https://docs.nhost.io/platform/cli/local-development) if you have not installed it yet
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# Nhost
|
# Nhost
|
||||||
|
|
||||||
<a href="https://docs.nhost.io/#quickstart">Quickstart</a>
|
<a href="https://docs.nhost.io/getting-started/overview">Quickstart</a>
|
||||||
<span> • </span>
|
<span> • </span>
|
||||||
<a href="http://nhost.io/">Website</a>
|
<a href="http://nhost.io/">Website</a>
|
||||||
<span> • </span>
|
<span> • </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/cli) for local development
|
- [Nhost CLI](https://docs.nhost.io/platform/cli/local-development) for local development
|
||||||
|
|
||||||
## Architecture of Nhost
|
## Architecture of Nhost
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ const nhost = new NhostClient({
|
|||||||
region: '<your-region>'
|
region: '<your-region>'
|
||||||
})
|
})
|
||||||
|
|
||||||
await nhost.auth.signIn({ email: 'elon@musk.com', password: 'spaceX' })
|
await nhost.auth.signIn({ email: 'user@domain.com', password: 'userPassword' })
|
||||||
|
|
||||||
await nhost.graphql.request(`{
|
await nhost.graphql.request(`{
|
||||||
users {
|
users {
|
||||||
@@ -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/platform/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
|
<a href="https://docs.nhost.io/getting-started/quickstart/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/nuxtjs.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/platform/quickstarts/react"><img src="assets/react.svg"/></a>
|
<a href="https://docs.nhost.io/getting-started/quickstart/react"><img src="assets/react.svg"/></a>
|
||||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/react-native.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/svelte.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/platform/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
<a href="https://docs.nhost.io/getting-started/quickstart/vue"><img src="assets/vuejs.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Resources
|
# Resources
|
||||||
|
|
||||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/cli)
|
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
|
||||||
|
|
||||||
## Nhost Clients
|
## Nhost Clients
|
||||||
|
|
||||||
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript)
|
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript/nhost-js/nhost-client)
|
||||||
- [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)
|
- [React](https://docs.nhost.io/reference/react/nhost-client)
|
||||||
- [Next.js](https://docs.nhost.io/reference/nextjs)
|
- [Next.js](https://docs.nhost.io/reference/nextjs/nhost-client)
|
||||||
- [Vue](https://docs.nhost.io/reference/vue)
|
- [Vue](https://docs.nhost.io/reference/vue/nhost-client)
|
||||||
|
|
||||||
## 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/get-started/quick-start)**, and think of ways to make the service better. Let us know here on GitHub.
|
- **[Try out Nhost](https://docs.nhost.io)**, 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!
|
||||||
|
|
||||||
@@ -150,4 +150,4 @@ Here are some ways of contributing to making Nhost better:
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<img width="720" src="https://contrib.rocks/image?repo=nhost/nhost" alt="A table of avatars from the project's contributors" />
|
<img width="720" src="https://contrib.rocks/image?repo=nhost/nhost" alt="A table of avatars from the project's contributors" />
|
||||||
</p>
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ NEXT_PUBLIC_ENV=dev
|
|||||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||||
|
|
||||||
# Environment Variables for Self Hosting and Local Development
|
# Environment Variables for Self Hosting and Local Development
|
||||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.local.run/v1
|
||||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||||
|
|
||||||
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
|
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
|
||||||
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
||||||
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||||
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
||||||
|
NEXT_PUBLIC_SEGMENT_CDN_URL=<segment_cdn_url>
|
||||||
NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET=<nhost_bragi_websocket>
|
NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET=<nhost_bragi_websocket>
|
||||||
|
|
||||||
NEXT_PUBLIC_ZENDESK_URL=
|
NEXT_PUBLIC_ZENDESK_URL=
|
||||||
@@ -22,6 +23,6 @@ NEXT_PUBLIC_ZENDESK_API_KEY=
|
|||||||
NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||||
|
|
||||||
|
|
||||||
CODEGEN_GRAPHQL_URL=https://local.graphql.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
|
||||||
2
dashboard/.vscode/settings.json
vendored
2
dashboard/.vscode/settings.json
vendored
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"typescript.tsdk": "node_modules/typescript/lib",
|
"typescript.tsdk": "node_modules/typescript/lib",
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.organizeImports": true
|
"source.organizeImports": "explicit"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
# @nhost/dashboard
|
# @nhost/dashboard
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
|||||||
RUN yarn global add pnpm@9.15.0
|
RUN yarn global add pnpm@9.15.0
|
||||||
COPY .gitignore .gitignore
|
COPY .gitignore .gitignore
|
||||||
COPY --from=pruner /app/out/json/ .
|
COPY --from=pruner /app/out/json/ .
|
||||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
COPY --from=pruner /app/out/pnpm-*.yaml ./
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY --from=pruner /app/out/full/ .
|
COPY --from=pruner /app/out/full/ .
|
||||||
|
|||||||
@@ -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#installation).
|
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).
|
||||||
|
|
||||||
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:
|
||||||
|
|
||||||
@@ -51,13 +51,13 @@ You can connect the Nhost Dashboard to your locally running backend by setting t
|
|||||||
```bash
|
```bash
|
||||||
NEXT_PUBLIC_ENV=dev
|
NEXT_PUBLIC_ENV=dev
|
||||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||||
```
|
```
|
||||||
|
|
||||||
This will connect the Nhost Dashboard to your locally running Nhost backend.
|
This will connect the Nhost Dashboard to your locally running Nhost backend.
|
||||||
@@ -149,8 +149,11 @@ 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_WORKSPACE_NAME=<test_workspace_name>
|
NHOST_TEST_ORGANIZATION_NAME=<test_organization_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>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -159,11 +162,14 @@ 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_WORKSPACE_NAME`: Name of the workspace that contains the test project
|
- `NHOST_TEST_ORGANIZATION_NAME`: Name of the organization 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 workspace and project information from the [Nhost Dashboard](https://app.nhost.io/).
|
Make sure to copy the organization 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:
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
|
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';
|
|
||||||
|
|
||||||
let page: Page;
|
test('should be able to create then delete a personal access token', async ({
|
||||||
|
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();
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
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,
|
||||||
@@ -23,11 +14,9 @@ test.beforeEach(async () => {
|
|||||||
await page.waitForURL(AIRoute);
|
await page.waitForURL(AIRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create and delete an Assistant', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
let page: Page;
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
|
||||||
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,
|
||||||
@@ -23,11 +15,9 @@ test.beforeEach(async () => {
|
|||||||
await page.waitForURL(AIRoute);
|
await page.waitForURL(AIRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create and delete an Auto-Embeddings', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
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');
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import test, { expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('should be able to ban and unban a user', async ({ page }) => {
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
await gotoAuthURL(page);
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } 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';
|
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await gotoAuthURL(page);
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should create a user', 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 create a user', async () => {
|
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
@@ -31,7 +17,9 @@ test('should create a user', async () => {
|
|||||||
).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();
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
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';
|
|
||||||
|
|
||||||
let page: Page;
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
page = await browser.newPage();
|
await gotoAuthURL(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should be able to delete a user', async ({
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
authenticatedNhostPage: page,
|
||||||
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();
|
||||||
|
|
||||||
@@ -52,7 +41,9 @@ 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();
|
||||||
|
|
||||||
|
|||||||
31
dashboard/e2e/auth/edit-user.test.ts
Normal file
31
dashboard/e2e/auth/edit-user.test.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await gotoAuthURL(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to edit user roles from the details page', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
|
const email = generateTestEmail();
|
||||||
|
const password = faker.internet.password();
|
||||||
|
|
||||||
|
await createUser({ page, email, password });
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: `View ${email}`, exact: true })
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.locator('#defaultRole').click();
|
||||||
|
await page.getByRole('option', { name: /anonymous/i }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('anonymous').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText('User settings have been updated successfully.'),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,26 +1,14 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } 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';
|
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await gotoAuthURL(page);
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should be able to verify the email of a user', async ({
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
authenticatedNhostPage: page,
|
||||||
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();
|
||||||
|
|
||||||
@@ -50,7 +38,9 @@ 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();
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
test.describe('Local Dashboard CLI e2e tests', () => {
|
||||||
|
test('should redirect / to the correct project URL', async ({ page }) => {
|
||||||
|
await page.goto('https://local.dashboard.local.nhost.run/');
|
||||||
|
await page.waitForURL(
|
||||||
|
'https://local.dashboard.local.nhost.run/orgs/local/projects/local',
|
||||||
|
);
|
||||||
|
expect(page.url()).toBe(
|
||||||
|
'https://local.dashboard.local.nhost.run/orgs/local/projects/local',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should load the project URL correctly', async ({ page }) => {
|
||||||
|
const projectUrl =
|
||||||
|
'https://local.dashboard.local.nhost.run/orgs/local/projects/local';
|
||||||
|
await page.goto(projectUrl);
|
||||||
|
await expect(page).toHaveURL(projectUrl);
|
||||||
|
await expect(page.getByText(/Subdomain/i)).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,35 +1,18 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { navigateToProject, prepareTable } from '@/e2e/utils';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
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';
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: 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.afterAll(async () => {
|
test('should create a simple table', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
@@ -57,7 +40,9 @@ 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();
|
||||||
|
|
||||||
@@ -86,7 +71,9 @@ 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();
|
||||||
|
|
||||||
@@ -115,7 +102,9 @@ 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();
|
||||||
|
|
||||||
@@ -148,7 +137,9 @@ 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();
|
||||||
|
|
||||||
@@ -221,7 +212,9 @@ 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();
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { deleteTable, navigateToProject, prepareTable } from '@/e2e/utils';
|
import { deleteTable, 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 { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { snakeCase } from 'snake-case';
|
import { snakeCase } from 'snake-case';
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: 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.afterAll(async () => {
|
test('should delete a table', async ({ authenticatedNhostPage: page }) => {
|
||||||
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();
|
||||||
@@ -65,7 +47,9 @@ test('should delete a table', async () => {
|
|||||||
).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();
|
||||||
|
|||||||
130
dashboard/e2e/database/permissions-table.test.ts
Normal file
130
dashboard/e2e/database/permissions-table.test.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
import { clickPermissionButton, prepareTable } from '@/e2e/utils';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { snakeCase } from 'snake-case';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
|
await page.goto(databaseRoute);
|
||||||
|
await page.waitForURL(databaseRoute);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a table with role permissions to select row', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
|
const tableName = snakeCase(faker.lorem.words(3));
|
||||||
|
|
||||||
|
await prepareTable({
|
||||||
|
page,
|
||||||
|
name: tableName,
|
||||||
|
primaryKey: 'id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||||
|
{ name: 'title', type: 'text' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// create table
|
||||||
|
await page.getByRole('button', { name: /create/i }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(
|
||||||
|
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: tableName, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Press three horizontal dots more options button next to the table name
|
||||||
|
await page
|
||||||
|
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: /edit permissions/i }).click();
|
||||||
|
|
||||||
|
await clickPermissionButton({ page, role: 'user', permission: 'Select' });
|
||||||
|
|
||||||
|
await page.getByLabel('Without any checks').click();
|
||||||
|
await page.getByRole('button', { name: /select all/i }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/permission has been saved successfully/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
|
const tableName = snakeCase(faker.lorem.words(3));
|
||||||
|
|
||||||
|
await prepareTable({
|
||||||
|
page,
|
||||||
|
name: tableName,
|
||||||
|
primaryKey: 'id',
|
||||||
|
columns: [
|
||||||
|
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||||
|
{ name: 'title', type: 'text' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// create table
|
||||||
|
await page.getByRole('button', { name: /create/i }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(
|
||||||
|
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('link', { name: tableName, exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Press three horizontal dots more options button next to the table name
|
||||||
|
await page
|
||||||
|
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||||
|
.click();
|
||||||
|
|
||||||
|
await page.getByRole('menuitem', { name: /edit permissions/i }).click();
|
||||||
|
|
||||||
|
await clickPermissionButton({ page, role: 'user', permission: 'Select' });
|
||||||
|
|
||||||
|
await page.getByLabel('With custom check').click();
|
||||||
|
|
||||||
|
// await page.getByRole('combobox', { name: /select a column/i }).click();
|
||||||
|
await page.getByText('Select a column', { exact: true }).click();
|
||||||
|
|
||||||
|
const columnSelector = page.locator('input[role="combobox"]');
|
||||||
|
|
||||||
|
await columnSelector.fill('id');
|
||||||
|
|
||||||
|
await columnSelector.press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByText(/_eq/i)).toBeVisible();
|
||||||
|
|
||||||
|
// limit on number of rows fetched per request.
|
||||||
|
await page.locator('#limit').fill('100');
|
||||||
|
|
||||||
|
await page.getByText('Select variable...', { exact: true }).click();
|
||||||
|
|
||||||
|
const variableSelector = await page.locator('input[role="combobox"]');
|
||||||
|
|
||||||
|
await variableSelector.fill('X-Hasura-User-Id');
|
||||||
|
|
||||||
|
await variableSelector.press('Enter');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /select all/i }).click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: /save/i }).click();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByText(/permission has been saved successfully/i),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
@@ -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 workspace to test against.
|
* Name of the organization to test against.
|
||||||
*/
|
*/
|
||||||
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME;
|
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME;
|
||||||
|
|
||||||
|
|||||||
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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.context().storageState({ path: AUTH_CONTEXT });
|
||||||
|
await page.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import type { Page } from '@playwright/test';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { expect, test } from '@playwright/test';
|
import { navigateToProject } from '@/e2e/utils';
|
||||||
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,
|
||||||
@@ -17,11 +10,9 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should show the navtree with all links visible', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
@@ -42,16 +33,20 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -60,7 +55,9 @@ 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();
|
||||||
|
|||||||
@@ -1,33 +1,15 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import type { Page } from '@playwright/test';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
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.afterAll(async () => {
|
test('should create and delete a run service', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
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();
|
||||||
|
|||||||
36
dashboard/e2e/teardown/database.teardown.ts
Normal file
36
dashboard/e2e/teardown/database.teardown.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
|
import { expect, test as teardown } from '@/e2e/fixtures/auth-hook';
|
||||||
|
|
||||||
|
teardown.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
|
await page.goto(databaseRoute);
|
||||||
|
await page.waitForURL(databaseRoute);
|
||||||
|
});
|
||||||
|
|
||||||
|
teardown(
|
||||||
|
'clean up database tables',
|
||||||
|
async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||||
|
|
||||||
|
await page.waitForURL(
|
||||||
|
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const inputField = page.locator('[contenteditable]');
|
||||||
|
await inputField.fill(`
|
||||||
|
DO $$ DECLARE
|
||||||
|
tablename text;
|
||||||
|
BEGIN
|
||||||
|
FOR tablename IN
|
||||||
|
SELECT table_name FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
LOOP
|
||||||
|
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||||
|
END LOOP;
|
||||||
|
END $$;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||||
|
await expect(page.getByText(/success/i)).toBeVisible();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
@@ -191,3 +192,29 @@ export function generateTestEmail(prefix: string = 'Nhost_Test_') {
|
|||||||
|
|
||||||
return [prefix, email].join('');
|
return [prefix, email].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function clickPermissionButton({
|
||||||
|
page,
|
||||||
|
role,
|
||||||
|
permission,
|
||||||
|
}: {
|
||||||
|
page: Page;
|
||||||
|
role: string;
|
||||||
|
permission: 'Insert' | 'Select' | 'Update' | 'Delete';
|
||||||
|
}) {
|
||||||
|
const permissionIndex =
|
||||||
|
['Insert', 'Select', 'Update', 'Delete'].indexOf(permission) + 1;
|
||||||
|
|
||||||
|
await page
|
||||||
|
.locator('tr', { hasText: role })
|
||||||
|
.locator('td')
|
||||||
|
.nth(permissionIndex)
|
||||||
|
.locator('button')
|
||||||
|
.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gotoAuthURL(page) {
|
||||||
|
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||||
|
await page.goto(authUrl);
|
||||||
|
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import {
|
|
||||||
TEST_DASHBOARD_URL,
|
|
||||||
TEST_ORGANIZATION_SLUG,
|
|
||||||
TEST_PROJECT_ADMIN_SECRET,
|
|
||||||
TEST_PROJECT_SUBDOMAIN,
|
|
||||||
} from '@/e2e/env';
|
|
||||||
import { navigateToProject } from '@/e2e/utils';
|
|
||||||
import { chromium } from '@playwright/test';
|
|
||||||
|
|
||||||
async function globalTeardown() {
|
|
||||||
const browser = await chromium.launch({ slowMo: 1000 });
|
|
||||||
|
|
||||||
const context = await browser.newContext({
|
|
||||||
baseURL: TEST_DASHBOARD_URL,
|
|
||||||
storageState: 'e2e/.auth/user.json',
|
|
||||||
});
|
|
||||||
|
|
||||||
const page = await context.newPage();
|
|
||||||
|
|
||||||
await navigateToProject({
|
|
||||||
page,
|
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
|
||||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pagePromise = context.waitForEvent('page');
|
|
||||||
|
|
||||||
await page.getByRole('link', { name: /hasura/i }).click();
|
|
||||||
await page.getByRole('link', { name: /open hasura/i }).click();
|
|
||||||
|
|
||||||
const hasuraPage = await pagePromise;
|
|
||||||
await hasuraPage.waitForLoadState();
|
|
||||||
|
|
||||||
const adminSecretInput = hasuraPage.getByPlaceholder(/enter admin-secret/i);
|
|
||||||
|
|
||||||
// note: a more ideal way would be to paste from clipboard, but Playwright
|
|
||||||
// doesn't support that yet
|
|
||||||
await adminSecretInput.fill(TEST_PROJECT_ADMIN_SECRET);
|
|
||||||
await adminSecretInput.press('Enter');
|
|
||||||
|
|
||||||
// note: getByRole doesn't work here
|
|
||||||
await hasuraPage.locator('a', { hasText: /data/i }).nth(0).click();
|
|
||||||
await hasuraPage.locator('[data-test="sql-link"]').click();
|
|
||||||
|
|
||||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
|
||||||
await hasuraPage.evaluate(() => {
|
|
||||||
const editor = ace.edit('raw_sql');
|
|
||||||
|
|
||||||
editor.setValue(`
|
|
||||||
DO $$ DECLARE
|
|
||||||
tablename text;
|
|
||||||
BEGIN
|
|
||||||
FOR tablename IN
|
|
||||||
SELECT table_name FROM information_schema.tables
|
|
||||||
WHERE table_schema = 'public'
|
|
||||||
LOOP
|
|
||||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
|
||||||
END LOOP;
|
|
||||||
END $$;
|
|
||||||
`);
|
|
||||||
});
|
|
||||||
|
|
||||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
|
||||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
|
||||||
}
|
|
||||||
|
|
||||||
export default globalTeardown;
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
schema:
|
schema:
|
||||||
- https://local.graphql.nhost.run/v1:
|
- https://local.graphql.local.nhost.run/v1:
|
||||||
headers:
|
headers:
|
||||||
x-hasura-admin-secret: nhost-admin-secret
|
x-hasura-admin-secret: nhost-admin-secret
|
||||||
generates:
|
generates:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { version } = require('./package.json');
|
|||||||
const cspHeader = `
|
const cspHeader = `
|
||||||
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
||||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
||||||
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com;
|
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com;
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
||||||
font-src 'self' data:;
|
font-src 'self' data:;
|
||||||
@@ -16,6 +16,8 @@ const cspHeader = `
|
|||||||
form-action 'self';
|
form-action 'self';
|
||||||
frame-ancestors 'none';
|
frame-ancestors 'none';
|
||||||
frame-src 'self' js.stripe.com;
|
frame-src 'self' js.stripe.com;
|
||||||
|
block-all-mixed-content;
|
||||||
|
upgrade-insecure-requests;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
module.exports = withBundleAnalyzer({
|
module.exports = withBundleAnalyzer({
|
||||||
@@ -36,9 +38,13 @@ module.exports = withBundleAnalyzer({
|
|||||||
{
|
{
|
||||||
source: '/(.*)',
|
source: '/(.*)',
|
||||||
headers: [
|
headers: [
|
||||||
|
// {
|
||||||
|
// key: 'Content-Security-Policy',
|
||||||
|
// hgvalue: cspHeader.replace(/\s+/g, ' ').trim(),
|
||||||
|
// },
|
||||||
{
|
{
|
||||||
key: 'X-Frame-Options',
|
key: 'X-Frame-Options',
|
||||||
value: 'SAMEORIGIN',
|
value: 'DENY',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "2.18.0",
|
"version": "2.27.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -16,13 +16,15 @@
|
|||||||
"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": "pnpm install-browsers && pnpm playwright test"
|
"e2e": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
|
||||||
|
"e2e-local": "pnpm install-browsers && pnpm playwright test --config=playwright.local.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@apollo/client": "^3.9.9",
|
"@apollo/client": "^3.9.9",
|
||||||
"@codemirror/lang-sql": "^6.6.2",
|
"@codemirror/lang-sql": "^6.6.2",
|
||||||
"@codemirror/language": "^6.10.1",
|
"@codemirror/language": "^6.10.1",
|
||||||
"@codemirror/legacy-modes": "^6.4.0",
|
"@codemirror/legacy-modes": "^6.4.0",
|
||||||
|
"@date-fns/tz": "^1.2.0",
|
||||||
"@emotion/cache": "^11.11.0",
|
"@emotion/cache": "^11.11.0",
|
||||||
"@emotion/react": "^11.11.4",
|
"@emotion/react": "^11.11.4",
|
||||||
"@emotion/server": "^11.11.0",
|
"@emotion/server": "^11.11.0",
|
||||||
@@ -55,24 +57,25 @@
|
|||||||
"@radix-ui/react-select": "^2.1.2",
|
"@radix-ui/react-select": "^2.1.2",
|
||||||
"@radix-ui/react-separator": "^1.1.0",
|
"@radix-ui/react-separator": "^1.1.0",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.1.2",
|
"@radix-ui/react-tooltip": "^1.1.2",
|
||||||
"@segment/snippet": "^4.16.2",
|
"@segment/analytics-next": "^1.77.0",
|
||||||
"@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",
|
||||||
"@tanstack/react-query": "^4.36.1",
|
"@tanstack/react-query": "^4.36.1",
|
||||||
"@tanstack/react-table": "^8.15.3",
|
"@tanstack/react-table": "^8.15.3",
|
||||||
"@tanstack/react-virtual": "^3.2.0",
|
"@tanstack/react-virtual": "^3.5.0",
|
||||||
"@uidotdev/usehooks": "^2.4.1",
|
"@uidotdev/usehooks": "^2.4.1",
|
||||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||||
"@uiw/react-codemirror": "^4.21.25",
|
"@uiw/react-codemirror": "^4.21.25",
|
||||||
"analytics-node": "^6.2.0",
|
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^1.2.1",
|
"clsx": "^1.2.1",
|
||||||
"cmdk": "1.0.0",
|
"cmdk": "1.0.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"date-fns-v4": "npm:date-fns@4.1.0",
|
||||||
"dequal": "^2.0.3",
|
"dequal": "^2.0.3",
|
||||||
"framer-motion": "^10.18.0",
|
"framer-motion": "^10.18.0",
|
||||||
"generate-password": "^1.7.1",
|
"generate-password": "^1.7.1",
|
||||||
@@ -84,7 +87,7 @@
|
|||||||
"just-kebab-case": "^4.2.0",
|
"just-kebab-case": "^4.2.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lucide-react": "^0.416.0",
|
"lucide-react": "^0.416.0",
|
||||||
"next": "^14.2.22",
|
"next": "^14.2.25",
|
||||||
"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",
|
||||||
@@ -93,6 +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-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",
|
||||||
@@ -108,11 +112,11 @@
|
|||||||
"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",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"timezones-list": "^3.1.0",
|
||||||
"utility-types": "^3.11.0",
|
"utility-types": "^3.11.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.11.0",
|
||||||
@@ -152,7 +156,6 @@
|
|||||||
"@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",
|
||||||
@@ -191,7 +194,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.12",
|
"vite": "^5.4.17",
|
||||||
"vite-tsconfig-paths": "^4.3.2",
|
"vite-tsconfig-paths": "^4.3.2",
|
||||||
"vitest": "^0.32.4"
|
"vitest": "^0.32.4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ export default defineConfig({
|
|||||||
retries: process.env.CI ? 2 : 0,
|
retries: process.env.CI ? 2 : 0,
|
||||||
workers: 1,
|
workers: 1,
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
globalTeardown: require.resolve('./global-teardown'),
|
|
||||||
use: {
|
use: {
|
||||||
actionTimeout: 0,
|
actionTimeout: 0,
|
||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
@@ -28,6 +27,11 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
name: 'setup',
|
name: 'setup',
|
||||||
testMatch: ['**/setup/*.setup.ts'],
|
testMatch: ['**/setup/*.setup.ts'],
|
||||||
|
teardown: 'teardown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'teardown',
|
||||||
|
testMatch: ['**/teardown/*.teardown.ts'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'chromium',
|
name: 'chromium',
|
||||||
@@ -36,6 +40,7 @@ export default defineConfig({
|
|||||||
storageState: 'e2e/.auth/user.json',
|
storageState: 'e2e/.auth/user.json',
|
||||||
},
|
},
|
||||||
dependencies: ['setup'],
|
dependencies: ['setup'],
|
||||||
|
grepInvert: [/Local Dashboard CLI e2e tests/],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
31
dashboard/playwright.local.config.ts
Normal file
31
dashboard/playwright.local.config.ts
Normal 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/**'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
30
dashboard/src/components/analytics/analytics.tsx
Normal file
30
dashboard/src/components/analytics/analytics.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { analytics } from '@/lib/segment';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export default function Analytics() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { org } = useCurrentOrg();
|
||||||
|
const { project } = useProject();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const customProperties = {
|
||||||
|
organizationSlug: org?.slug || '',
|
||||||
|
projectSubdomain: project?.subdomain || '',
|
||||||
|
};
|
||||||
|
|
||||||
|
analytics.page(customProperties);
|
||||||
|
|
||||||
|
const handleRouteChange = () => analytics.page(customProperties);
|
||||||
|
|
||||||
|
router.events.on('routeChangeComplete', handleRouteChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
router.events.off('routeChangeComplete', handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [router.events, org?.slug, project?.subdomain]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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'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're here to help, so don't hesitate to reach out!</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './ContactUs';
|
|
||||||
export { default as ContactUs } from './ContactUs';
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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');
|
||||||
|
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { TimePicker } from '@/components/common/TimePicker';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import { Calendar } from '@/components/ui/v3/calendar';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||||
|
import { TZDate } from '@date-fns/tz';
|
||||||
|
import { add, format, parseISO } from 'date-fns-v4';
|
||||||
|
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import TimezoneSettings from './TimezoneSettings';
|
||||||
|
|
||||||
|
export interface DateTimePickerProps {
|
||||||
|
dateTime: string;
|
||||||
|
onDateTimeChange: (newDate: string) => void;
|
||||||
|
withTimezone?: boolean;
|
||||||
|
defaultTimezone?: string;
|
||||||
|
formatDateFn?: (date: Date | string) => string;
|
||||||
|
isCalendarDayDisabled?: (date: Date) => boolean;
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
validateDateFn?: (date: Date) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateTimePicker({
|
||||||
|
dateTime,
|
||||||
|
withTimezone = false,
|
||||||
|
defaultTimezone,
|
||||||
|
formatDateFn,
|
||||||
|
onDateTimeChange,
|
||||||
|
isCalendarDayDisabled,
|
||||||
|
align = 'start',
|
||||||
|
validateDateFn,
|
||||||
|
}: DateTimePickerProps) {
|
||||||
|
const [date, setDate] = useState(() => {
|
||||||
|
if (withTimezone) {
|
||||||
|
const tz = defaultTimezone || guessTimezone();
|
||||||
|
return new TZDate(dateTime, tz);
|
||||||
|
}
|
||||||
|
return parseISO(dateTime);
|
||||||
|
});
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const [timezone, setTimezone] = useState(
|
||||||
|
() => defaultTimezone || guessTimezone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function emitNewDateTime() {
|
||||||
|
onDateTimeChange(new Date(date.getTime()).toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* carry over the current time when a user clicks a new day
|
||||||
|
* instead of resetting to 00:00
|
||||||
|
*/
|
||||||
|
function handleSelect(newDay: Date | undefined) {
|
||||||
|
if (!newDay) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!date) {
|
||||||
|
setDate(newDay);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const diff = newDay.getTime() - date.getTime();
|
||||||
|
const diffInDays = diff / (1000 * 60 * 60 * 24);
|
||||||
|
const newDateFull = add(date, { days: Math.ceil(diffInDays) });
|
||||||
|
setDate(newDateFull);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimezoneChange(newTimezone: string) {
|
||||||
|
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
|
||||||
|
setTimezone(newTimezone);
|
||||||
|
setDate(newDateWithTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(newOpenState: boolean) {
|
||||||
|
if (!newOpenState) {
|
||||||
|
if (withTimezone) {
|
||||||
|
const tz = defaultTimezone || guessTimezone();
|
||||||
|
setTimezone(tz);
|
||||||
|
setDate(new TZDate(dateTime, tz));
|
||||||
|
}
|
||||||
|
setDate(parseISO(dateTime));
|
||||||
|
}
|
||||||
|
setOpen(newOpenState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect() {
|
||||||
|
emitNewDateTime();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDateInUTC = new Date(date.getTime()).toISOString();
|
||||||
|
|
||||||
|
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
|
||||||
|
|
||||||
|
const errorText = validateDateFn?.(date);
|
||||||
|
const hasError = !!errorText;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
data-testid="dateTimePickerTrigger"
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
'w-full justify-between text-left font-normal',
|
||||||
|
!date && 'text-muted-foreground',
|
||||||
|
{ 'border-destructive': hasError },
|
||||||
|
)}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
{date ? dateString : <span>Pick a date</span>}
|
||||||
|
<CalendarIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent className="w-auto p-0" align={align}>
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={date}
|
||||||
|
defaultMonth={date}
|
||||||
|
onSelect={(d) => handleSelect(d)}
|
||||||
|
disabled={isCalendarDayDisabled}
|
||||||
|
timeZone={timezone}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
<TimePicker setDate={setDate} date={date} />
|
||||||
|
</div>
|
||||||
|
{withTimezone && (
|
||||||
|
<div className="border-t border-border p-3">
|
||||||
|
<TimezoneSettings
|
||||||
|
dateTime={selectedDateInUTC}
|
||||||
|
onTimezoneChange={handleTimezoneChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row justify-between gap-5 p-3">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={hasError}
|
||||||
|
>
|
||||||
|
Select
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn('p-3 text-center text-[11px] text-destructive', {
|
||||||
|
invisible: !hasError,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{errorText}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DateTimePicker;
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { TimezonePicker } from '@/components/common/TimezonePicker';
|
||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import { getUTCOffsetInHours, guessTimezone } from '@/utils/timezoneUtils';
|
||||||
|
import { Settings2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dateTime: string;
|
||||||
|
onTimezoneChange: (timezone: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
|
||||||
|
const [selectedTimezone, setTimezone] = useState<string>(() =>
|
||||||
|
guessTimezone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleTimezoneSelect(tz: { value: string; label: string }) {
|
||||||
|
setTimezone(tz.value);
|
||||||
|
onTimezoneChange?.(tz.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<span>Timezone: {utcOffset}</span>
|
||||||
|
<TimezonePicker
|
||||||
|
dateTime={dateTime}
|
||||||
|
selectedTimezone={selectedTimezone}
|
||||||
|
onTimezoneSelect={handleTimezoneSelect}
|
||||||
|
button={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
aria-label="Open timezone settings"
|
||||||
|
data-testid="timezoneSettingsButton"
|
||||||
|
>
|
||||||
|
<Settings2 className="h-4 w-4 dark:text-foreground" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimezoneSettings;
|
||||||
1
dashboard/src/components/common/DateTimePicker/index.ts
Normal file
1
dashboard/src/components/common/DateTimePicker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as DateTimePicker } from './DateTimePicker';
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default as InviteNotification } from './InviteNotification';
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useDialog } from '@/components/common/DialogProvider';
|
||||||
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
buttonText?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OpenTransferDialogButton({ buttonText, onClick }: Props) {
|
||||||
|
const text = buttonText ?? 'Transfer Project';
|
||||||
|
const isOwner = useIsCurrentUserOwner();
|
||||||
|
const { openAlertDialog } = useDialog();
|
||||||
|
const handleClick = () => {
|
||||||
|
if (isOwner) {
|
||||||
|
onClick();
|
||||||
|
} else {
|
||||||
|
openAlertDialog({
|
||||||
|
title: "You can't migrate this project",
|
||||||
|
payload: (
|
||||||
|
<Text variant="subtitle1" component="span">
|
||||||
|
Ask an owner of this organization to migrate the project.
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
props: {
|
||||||
|
secondaryButtonText: 'I understand',
|
||||||
|
hidePrimaryAction: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Button className="max-w-xs lg:w-auto" onClick={handleClick}>
|
||||||
|
{text}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OpenTransferDialogButton;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as OpenTransferDialogButton } from './OpenTransferDialogButton';
|
||||||
104
dashboard/src/components/common/TimePicker/TimePicker.test.tsx
Normal file
104
dashboard/src/components/common/TimePicker/TimePicker.test.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { render, screen, TestUserEvent } from '@/tests/testUtils';
|
||||||
|
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||||
|
import { TZDate } from '@date-fns/tz';
|
||||||
|
import { parseISO } from 'date-fns';
|
||||||
|
import { format } from 'date-fns-v4';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import TimePicker from './TimePicker';
|
||||||
|
|
||||||
|
function TestComponent({
|
||||||
|
dateTime,
|
||||||
|
withTimezone,
|
||||||
|
}: {
|
||||||
|
dateTime: string;
|
||||||
|
withTimezone?: boolean;
|
||||||
|
}) {
|
||||||
|
const [date, setDate] = useState(() => {
|
||||||
|
if (withTimezone) {
|
||||||
|
const tz = guessTimezone();
|
||||||
|
return new TZDate(dateTime, tz);
|
||||||
|
}
|
||||||
|
return parseISO(dateTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1>Time: {format(date, 'HH:mm:ss')}</h1>
|
||||||
|
<h1>Date class: {date instanceof TZDate ? 'TZDate' : 'Date'}</h1>
|
||||||
|
<TimePicker date={date} setDate={setDate} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
describe('TimePicker', () => {
|
||||||
|
test('Updates only the hour of the date object', async () => {
|
||||||
|
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||||
|
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||||
|
'Time: 03:00:05',
|
||||||
|
);
|
||||||
|
const user = new TestUserEvent();
|
||||||
|
const hoursInput = await screen.getByLabelText('Hours');
|
||||||
|
await user.type(hoursInput, '18');
|
||||||
|
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||||
|
'Time: 18:00:05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only valid hours(0-23), minutes(0-59) and seconds(0-59) are allowed', async () => {
|
||||||
|
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||||
|
const user = new TestUserEvent();
|
||||||
|
const hoursInput = await screen.getByLabelText('Hours');
|
||||||
|
await user.type(hoursInput, '30');
|
||||||
|
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||||
|
'Time: 23:00:05',
|
||||||
|
);
|
||||||
|
const minutesInput = await screen.getByLabelText('Minutes');
|
||||||
|
await user.type(minutesInput, '66');
|
||||||
|
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||||
|
'Time: 23:59:05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Updates only the minutes of the date object', async () => {
|
||||||
|
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||||
|
const user = new TestUserEvent();
|
||||||
|
const minutesInput = await screen.getByLabelText('Minutes');
|
||||||
|
await user.type(minutesInput, '44');
|
||||||
|
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||||
|
'Time: 03:44:05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Updates only the seconds of the date object', async () => {
|
||||||
|
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||||
|
const user = new TestUserEvent();
|
||||||
|
const secondsInput = await screen.getByLabelText('Seconds');
|
||||||
|
await user.type(secondsInput, '11');
|
||||||
|
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||||
|
'Time: 03:00:11',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("will preserve the date's class after changing the date", async () => {
|
||||||
|
render(<TestComponent dateTime="2025-03-10T03:00:05" withTimezone />);
|
||||||
|
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||||
|
'Date class: TZDate',
|
||||||
|
);
|
||||||
|
const user = new TestUserEvent();
|
||||||
|
|
||||||
|
const hoursInput = await screen.getByLabelText('Hours');
|
||||||
|
await user.type(hoursInput, '18');
|
||||||
|
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||||
|
'Date class: TZDate',
|
||||||
|
);
|
||||||
|
const secondsInput = await screen.getByLabelText('Seconds');
|
||||||
|
await user.type(secondsInput, '11');
|
||||||
|
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||||
|
'Date class: TZDate',
|
||||||
|
);
|
||||||
|
const minutesInput = await screen.getByLabelText('Minutes');
|
||||||
|
await user.type(minutesInput, '44');
|
||||||
|
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||||
|
'Date class: TZDate',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
64
dashboard/src/components/common/TimePicker/TimePicker.tsx
Normal file
64
dashboard/src/components/common/TimePicker/TimePicker.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Label } from '@/components/ui/v3/label';
|
||||||
|
import { Clock } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { TimePickerInput } from './TimePickerInput';
|
||||||
|
|
||||||
|
interface TimePickerProps {
|
||||||
|
date: Date | undefined;
|
||||||
|
setDate: (date: Date | undefined) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TimePicker({ date, setDate }: TimePickerProps) {
|
||||||
|
const minuteRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const hourRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
const secondRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="grid gap-1 text-center">
|
||||||
|
<Label htmlFor="hours" className="text-xs">
|
||||||
|
Hours
|
||||||
|
</Label>
|
||||||
|
<TimePickerInput
|
||||||
|
picker="hours"
|
||||||
|
date={date}
|
||||||
|
setDate={setDate}
|
||||||
|
ref={hourRef}
|
||||||
|
onRightFocus={() => minuteRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 text-center">
|
||||||
|
<Label htmlFor="minutes" className="text-xs">
|
||||||
|
Minutes
|
||||||
|
</Label>
|
||||||
|
<TimePickerInput
|
||||||
|
picker="minutes"
|
||||||
|
date={date}
|
||||||
|
setDate={setDate}
|
||||||
|
ref={minuteRef}
|
||||||
|
onLeftFocus={() => hourRef.current?.focus()}
|
||||||
|
onRightFocus={() => secondRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-1 text-center">
|
||||||
|
<Label htmlFor="seconds" className="text-xs">
|
||||||
|
Seconds
|
||||||
|
</Label>
|
||||||
|
<TimePickerInput
|
||||||
|
picker="seconds"
|
||||||
|
date={date}
|
||||||
|
setDate={setDate}
|
||||||
|
ref={secondRef}
|
||||||
|
onLeftFocus={() => minuteRef.current?.focus()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-10 items-center">
|
||||||
|
<Clock className="ml-2 h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TimePicker;
|
||||||
148
dashboard/src/components/common/TimePicker/TimePickerInput.tsx
Normal file
148
dashboard/src/components/common/TimePicker/TimePickerInput.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { Input } from '@/components/ui/v3/input';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
copyDate,
|
||||||
|
getArrowByType,
|
||||||
|
getDateByType,
|
||||||
|
setDateByType,
|
||||||
|
type Period,
|
||||||
|
type TimePickerType,
|
||||||
|
} from './time-picker-utils';
|
||||||
|
|
||||||
|
export interface TimePickerInputProps
|
||||||
|
extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
picker: TimePickerType;
|
||||||
|
date: Date | undefined;
|
||||||
|
setDate: (date: Date | undefined) => void;
|
||||||
|
period?: Period;
|
||||||
|
onRightFocus?: () => void;
|
||||||
|
onLeftFocus?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimePickerInput = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
TimePickerInputProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
className,
|
||||||
|
type = 'tel',
|
||||||
|
value,
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
date = new Date(new Date().setHours(0, 0, 0, 0)),
|
||||||
|
setDate,
|
||||||
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
|
picker,
|
||||||
|
period,
|
||||||
|
onLeftFocus,
|
||||||
|
onRightFocus,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [flag, setFlag] = React.useState<boolean>(false);
|
||||||
|
const [prevIntKey, setPrevIntKey] = React.useState<string>('0');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* allow the user to enter the second digit within 2 seconds
|
||||||
|
* otherwise start again with entering first digit
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line consistent-return
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (flag) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setFlag(false);
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [flag]);
|
||||||
|
|
||||||
|
const calculatedValue = React.useMemo(
|
||||||
|
() => getDateByType(date, picker),
|
||||||
|
[date, picker],
|
||||||
|
);
|
||||||
|
|
||||||
|
const calculateNewValue = (key: string) => {
|
||||||
|
/*
|
||||||
|
* If picker is '12hours' and the first digit is 0, then the second digit is automatically set to 1.
|
||||||
|
* The second entered digit will break the condition and the value will be set to 10-12.
|
||||||
|
*/
|
||||||
|
if (picker === '12hours') {
|
||||||
|
if (flag && calculatedValue.slice(1, 2) === '1' && prevIntKey === '0') {
|
||||||
|
return `0${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return !flag ? `0${key}` : calculatedValue.slice(1, 2) + key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.key === 'ArrowRight') {
|
||||||
|
onRightFocus?.();
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowLeft') {
|
||||||
|
onLeftFocus?.();
|
||||||
|
}
|
||||||
|
if (['ArrowUp', 'ArrowDown'].includes(e.key)) {
|
||||||
|
const step = e.key === 'ArrowUp' ? 1 : -1;
|
||||||
|
const newValue = getArrowByType(calculatedValue, step, picker);
|
||||||
|
if (flag) {
|
||||||
|
setFlag(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDate = copyDate(date);
|
||||||
|
setDate(setDateByType(tempDate, newValue, picker, period));
|
||||||
|
}
|
||||||
|
if (e.key >= '0' && e.key <= '9') {
|
||||||
|
if (picker === '12hours') {
|
||||||
|
setPrevIntKey(e.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newValue = calculateNewValue(e.key);
|
||||||
|
if (flag) {
|
||||||
|
onRightFocus?.();
|
||||||
|
}
|
||||||
|
setFlag((prev) => !prev);
|
||||||
|
const tempDate = copyDate(date);
|
||||||
|
setDate(setDateByType(tempDate, newValue, picker, period));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
id={id || picker}
|
||||||
|
name={name || picker}
|
||||||
|
className={cn(
|
||||||
|
'w-[48px] text-center font-mono text-base tabular-nums focus:bg-accent focus:text-accent-foreground [&::-webkit-inner-spin-button]:appearance-none',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
value={value || calculatedValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange?.(e);
|
||||||
|
}}
|
||||||
|
type={type}
|
||||||
|
inputMode="decimal"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
onKeyDown?.(e);
|
||||||
|
handleKeyDown(e);
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
TimePickerInput.displayName = 'TimePickerInput';
|
||||||
|
|
||||||
|
export { TimePickerInput };
|
||||||
1
dashboard/src/components/common/TimePicker/index.ts
Normal file
1
dashboard/src/components/common/TimePicker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as TimePicker } from './TimePicker';
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
import { vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
convert12HourTo24Hour,
|
||||||
|
display12HourValue,
|
||||||
|
getArrowByType,
|
||||||
|
getDateByType,
|
||||||
|
getValid12Hour,
|
||||||
|
getValidArrow12Hour,
|
||||||
|
getValidArrowHour,
|
||||||
|
getValidArrowMinuteOrSecond,
|
||||||
|
getValidArrowNumber,
|
||||||
|
getValidHour,
|
||||||
|
getValidMinuteOrSecond,
|
||||||
|
getValidNumber,
|
||||||
|
isValid12Hour,
|
||||||
|
isValidHour,
|
||||||
|
isValidMinuteOrSecond,
|
||||||
|
set12Hours,
|
||||||
|
setDateByType,
|
||||||
|
setHours,
|
||||||
|
setMinutes,
|
||||||
|
setSeconds,
|
||||||
|
type TimePickerType,
|
||||||
|
} from './time-picker-utils';
|
||||||
|
|
||||||
|
// Mock TZDate if needed
|
||||||
|
vi.mock('@date-fns/tz', () => ({
|
||||||
|
TZDate: class MockTZDate extends Date {
|
||||||
|
timeZone: string;
|
||||||
|
|
||||||
|
constructor(date: string | Date, timeZone: string) {
|
||||||
|
super(date);
|
||||||
|
this.timeZone = timeZone;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('time-picker-utils', () => {
|
||||||
|
describe('validation functions', () => {
|
||||||
|
test('isValidHour validates hour format correctly', () => {
|
||||||
|
// Valid hours
|
||||||
|
expect(isValidHour('00')).toBe(true);
|
||||||
|
expect(isValidHour('01')).toBe(true);
|
||||||
|
expect(isValidHour('12')).toBe(true);
|
||||||
|
expect(isValidHour('23')).toBe(true);
|
||||||
|
|
||||||
|
// Invalid hours
|
||||||
|
expect(isValidHour('24')).toBe(false);
|
||||||
|
expect(isValidHour('-1')).toBe(false);
|
||||||
|
expect(isValidHour('1')).toBe(false); // not padded
|
||||||
|
expect(isValidHour('ab')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isValid12Hour validates 12-hour format correctly', () => {
|
||||||
|
// Valid 12-hour values
|
||||||
|
expect(isValid12Hour('01')).toBe(true);
|
||||||
|
expect(isValid12Hour('09')).toBe(true);
|
||||||
|
expect(isValid12Hour('12')).toBe(true);
|
||||||
|
|
||||||
|
// Invalid 12-hour values
|
||||||
|
expect(isValid12Hour('00')).toBe(false);
|
||||||
|
expect(isValid12Hour('13')).toBe(false);
|
||||||
|
expect(isValid12Hour('1')).toBe(false); // not padded
|
||||||
|
expect(isValid12Hour('ab')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isValidMinuteOrSecond validates minute/second format correctly', () => {
|
||||||
|
// Valid minutes/seconds
|
||||||
|
expect(isValidMinuteOrSecond('00')).toBe(true);
|
||||||
|
expect(isValidMinuteOrSecond('01')).toBe(true);
|
||||||
|
expect(isValidMinuteOrSecond('30')).toBe(true);
|
||||||
|
expect(isValidMinuteOrSecond('59')).toBe(true);
|
||||||
|
|
||||||
|
// Invalid minutes/seconds
|
||||||
|
expect(isValidMinuteOrSecond('60')).toBe(false);
|
||||||
|
expect(isValidMinuteOrSecond('-1')).toBe(false);
|
||||||
|
expect(isValidMinuteOrSecond('1')).toBe(false); // not padded
|
||||||
|
expect(isValidMinuteOrSecond('ab')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('number validation and correction functions', () => {
|
||||||
|
test('getValidNumber handles number validation correctly', () => {
|
||||||
|
// Basic validation
|
||||||
|
expect(getValidNumber('5', { max: 10 })).toBe('05');
|
||||||
|
expect(getValidNumber('15', { max: 10 })).toBe('10');
|
||||||
|
expect(getValidNumber('-1', { max: 10, min: 0 })).toBe('00');
|
||||||
|
|
||||||
|
// With looping
|
||||||
|
expect(getValidNumber('15', { max: 10, min: 0, loop: true })).toBe('00');
|
||||||
|
expect(getValidNumber('-1', { max: 10, min: 0, loop: true })).toBe('10');
|
||||||
|
|
||||||
|
// Invalid input
|
||||||
|
expect(getValidNumber('abc', { max: 10 })).toBe('00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidHour returns valid 24-hour format', () => {
|
||||||
|
expect(getValidHour('12')).toBe('12');
|
||||||
|
expect(getValidHour('23')).toBe('23');
|
||||||
|
expect(getValidHour('24')).toBe('23'); // Capped at 23
|
||||||
|
expect(getValidHour('-1')).toBe('00'); // Min is 0
|
||||||
|
expect(getValidHour('abc')).toBe('00'); // Invalid input
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValid12Hour returns valid 12-hour format', () => {
|
||||||
|
// expect(getValid12Hour('06')).toBe('06');
|
||||||
|
// expect(getValid12Hour('12')).toBe('12');
|
||||||
|
expect(getValid12Hour('00')).toBe('01'); // Min is 1
|
||||||
|
expect(getValid12Hour('13')).toBe('12'); // Capped at 12
|
||||||
|
expect(getValid12Hour('abc')).toBe('00'); // Invalid input defaults to 00
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidMinuteOrSecond returns valid minute/second format', () => {
|
||||||
|
expect(getValidMinuteOrSecond('30')).toBe('30');
|
||||||
|
expect(getValidMinuteOrSecond('59')).toBe('59');
|
||||||
|
expect(getValidMinuteOrSecond('60')).toBe('59'); // Capped at 59
|
||||||
|
expect(getValidMinuteOrSecond('-1')).toBe('00'); // Min is 0
|
||||||
|
expect(getValidMinuteOrSecond('abc')).toBe('00'); // Invalid input
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('arrow navigation functions', () => {
|
||||||
|
test('getValidArrowNumber handles arrow navigation with looping', () => {
|
||||||
|
// Incrementing
|
||||||
|
expect(getValidArrowNumber('05', { min: 0, max: 10, step: 1 })).toBe(
|
||||||
|
'06',
|
||||||
|
);
|
||||||
|
expect(getValidArrowNumber('10', { min: 0, max: 10, step: 1 })).toBe(
|
||||||
|
'00',
|
||||||
|
); // Loops back to min
|
||||||
|
|
||||||
|
// Decrementing
|
||||||
|
expect(getValidArrowNumber('05', { min: 0, max: 10, step: -1 })).toBe(
|
||||||
|
'04',
|
||||||
|
);
|
||||||
|
expect(getValidArrowNumber('00', { min: 0, max: 10, step: -1 })).toBe(
|
||||||
|
'10',
|
||||||
|
); // Loops to max
|
||||||
|
|
||||||
|
// Invalid input
|
||||||
|
expect(getValidArrowNumber('abc', { min: 0, max: 10, step: 1 })).toBe(
|
||||||
|
'00',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidArrowHour handles hour navigation correctly', () => {
|
||||||
|
expect(getValidArrowHour('05', 1)).toBe('06');
|
||||||
|
expect(getValidArrowHour('23', 1)).toBe('00'); // Loops to 0
|
||||||
|
expect(getValidArrowHour('00', -1)).toBe('23'); // Loops to 23
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidArrow12Hour handles 12-hour navigation correctly', () => {
|
||||||
|
expect(getValidArrow12Hour('05', 1)).toBe('06');
|
||||||
|
expect(getValidArrow12Hour('12', 1)).toBe('01'); // Loops to 1
|
||||||
|
expect(getValidArrow12Hour('01', -1)).toBe('12'); // Loops to 12
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getValidArrowMinuteOrSecond handles minute/second navigation correctly', () => {
|
||||||
|
expect(getValidArrowMinuteOrSecond('30', 1)).toBe('31');
|
||||||
|
expect(getValidArrowMinuteOrSecond('59', 1)).toBe('00'); // Loops to 0
|
||||||
|
expect(getValidArrowMinuteOrSecond('00', -1)).toBe('59'); // Loops to 59
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('date manipulation functions', () => {
|
||||||
|
test('setMinutes sets minutes correctly on a Date object', () => {
|
||||||
|
const date = new Date(2023, 0, 1, 12, 0, 0);
|
||||||
|
setMinutes(date, '30');
|
||||||
|
expect(date.getMinutes()).toBe(30);
|
||||||
|
|
||||||
|
// Invalid values are corrected
|
||||||
|
setMinutes(date, '60');
|
||||||
|
expect(date.getMinutes()).toBe(59);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSeconds sets seconds correctly on a Date object', () => {
|
||||||
|
const date = new Date(2023, 0, 1, 12, 30, 0);
|
||||||
|
setSeconds(date, '45');
|
||||||
|
expect(date.getSeconds()).toBe(45);
|
||||||
|
|
||||||
|
// Invalid values are corrected
|
||||||
|
setSeconds(date, '60');
|
||||||
|
expect(date.getSeconds()).toBe(59);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setHours sets hours correctly on a Date object', () => {
|
||||||
|
const date = new Date(2023, 0, 1, 12, 30, 0);
|
||||||
|
setHours(date, '14');
|
||||||
|
expect(date.getHours()).toBe(14);
|
||||||
|
|
||||||
|
// Invalid values are corrected
|
||||||
|
setHours(date, '24');
|
||||||
|
expect(date.getHours()).toBe(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('convert12HourTo24Hour converts 12-hour to 24-hour format correctly', () => {
|
||||||
|
// AM conversions
|
||||||
|
expect(convert12HourTo24Hour(1, 'AM')).toBe(1);
|
||||||
|
expect(convert12HourTo24Hour(11, 'AM')).toBe(11);
|
||||||
|
expect(convert12HourTo24Hour(12, 'AM')).toBe(0); // 12 AM is 00:00
|
||||||
|
|
||||||
|
// PM conversions
|
||||||
|
expect(convert12HourTo24Hour(1, 'PM')).toBe(13);
|
||||||
|
expect(convert12HourTo24Hour(11, 'PM')).toBe(23);
|
||||||
|
expect(convert12HourTo24Hour(12, 'PM')).toBe(12); // 12 PM is 12:00
|
||||||
|
});
|
||||||
|
|
||||||
|
test('set12Hours sets 12-hour format correctly on a Date object', () => {
|
||||||
|
const date = new Date(2023, 0, 1, 0, 0, 0);
|
||||||
|
|
||||||
|
// Morning hours (AM)
|
||||||
|
set12Hours(date, '09', 'AM');
|
||||||
|
expect(date.getHours()).toBe(9);
|
||||||
|
|
||||||
|
// 12 AM
|
||||||
|
set12Hours(date, '12', 'AM');
|
||||||
|
expect(date.getHours()).toBe(0);
|
||||||
|
|
||||||
|
// Afternoon/evening hours (PM)
|
||||||
|
set12Hours(date, '03', 'PM');
|
||||||
|
expect(date.getHours()).toBe(15);
|
||||||
|
|
||||||
|
// 12 PM
|
||||||
|
set12Hours(date, '12', 'PM');
|
||||||
|
expect(date.getHours()).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('display12HourValue converts 24-hour to 12-hour display format', () => {
|
||||||
|
expect(display12HourValue(0)).toBe('12'); // 00:00 -> 12 AM
|
||||||
|
expect(display12HourValue(1)).toBe('01'); // 01:00 -> 1 AM
|
||||||
|
expect(display12HourValue(11)).toBe('11'); // 11:00 -> 11 AM
|
||||||
|
expect(display12HourValue(12)).toBe('12'); // 12:00 -> 12 PM
|
||||||
|
expect(display12HourValue(13)).toBe('01'); // 13:00 -> 1 PM
|
||||||
|
expect(display12HourValue(23)).toBe('11'); // 23:00 -> 11 PM
|
||||||
|
expect(display12HourValue(22)).toBe('10'); // 22:00 -> 10 PM
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe('integrated date manipulation functions', () => {
|
||||||
|
test('getDateByType returns date component according to the picker type', () => {
|
||||||
|
const date = new Date(2023, 0, 1, 14, 30, 45);
|
||||||
|
|
||||||
|
// Test hours
|
||||||
|
expect(getDateByType(date, 'hours')).toBe('14');
|
||||||
|
|
||||||
|
// Test minutes
|
||||||
|
expect(getDateByType(date, 'minutes')).toBe('30');
|
||||||
|
|
||||||
|
// Test seconds
|
||||||
|
expect(getDateByType(date, 'seconds')).toBe('45');
|
||||||
|
|
||||||
|
// Test 12-hour format
|
||||||
|
expect(getDateByType(date, '12hours')).toBe('02'); // 14:00 -> 2 PM
|
||||||
|
|
||||||
|
// Test 12 noon and midnight special cases
|
||||||
|
const noon = new Date(2023, 0, 1, 12, 0, 0);
|
||||||
|
expect(getDateByType(noon, '12hours')).toBe('12');
|
||||||
|
|
||||||
|
const midnight = new Date(2023, 0, 1, 0, 0, 0);
|
||||||
|
expect(getDateByType(midnight, '12hours')).toBe('12');
|
||||||
|
|
||||||
|
// Test with invalid picker type
|
||||||
|
expect(getDateByType(date, 'invalid' as TimePickerType)).toBe('00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getArrowByType handles arrow navigation based on picker type', () => {
|
||||||
|
// Test hours
|
||||||
|
expect(getArrowByType('14', 1, 'hours')).toBe('15');
|
||||||
|
expect(getArrowByType('23', 1, 'hours')).toBe('00'); // Loops back to 00
|
||||||
|
|
||||||
|
// Test minutes
|
||||||
|
expect(getArrowByType('30', 1, 'minutes')).toBe('31');
|
||||||
|
expect(getArrowByType('59', 1, 'minutes')).toBe('00'); // Loops back to 00
|
||||||
|
|
||||||
|
// Test seconds
|
||||||
|
expect(getArrowByType('45', 1, 'seconds')).toBe('46');
|
||||||
|
expect(getArrowByType('59', 1, 'seconds')).toBe('00'); // Loops back to 00
|
||||||
|
|
||||||
|
// Test 12-hour format
|
||||||
|
expect(getArrowByType('09', 1, '12hours')).toBe('10');
|
||||||
|
expect(getArrowByType('12', 1, '12hours')).toBe('01'); // Loops back to 01
|
||||||
|
|
||||||
|
// Test with invalid picker type
|
||||||
|
expect(getArrowByType('14', 1, 'invalid' as TimePickerType)).toBe('00');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setDateByType updates date according to the picker type', () => {
|
||||||
|
const date = new Date(2023, 0, 1, 12, 30, 45);
|
||||||
|
|
||||||
|
// Test updating hours
|
||||||
|
const hourDate = setDateByType(date, '14', 'hours');
|
||||||
|
expect(hourDate.getHours()).toBe(14);
|
||||||
|
expect(hourDate.getMinutes()).toBe(30); // Other fields unchanged
|
||||||
|
expect(hourDate.getSeconds()).toBe(45); // Other fields unchanged
|
||||||
|
|
||||||
|
// Test updating minutes
|
||||||
|
const minuteDate = setDateByType(date, '15', 'minutes');
|
||||||
|
expect(minuteDate.getHours()).toBe(14); // Other fields unchanged
|
||||||
|
expect(minuteDate.getMinutes()).toBe(15);
|
||||||
|
expect(minuteDate.getSeconds()).toBe(45); // Other fields unchanged
|
||||||
|
|
||||||
|
// Test updating seconds
|
||||||
|
const secondDate = setDateByType(date, '20', 'seconds');
|
||||||
|
expect(secondDate.getHours()).toBe(14); // Other fields unchanged
|
||||||
|
expect(secondDate.getMinutes()).toBe(15); // Other fields unchanged
|
||||||
|
expect(secondDate.getSeconds()).toBe(20);
|
||||||
|
|
||||||
|
// Test updating 12-hour format with AM
|
||||||
|
const amDate = setDateByType(date, '09', '12hours', 'AM');
|
||||||
|
expect(amDate.getHours()).toBe(9);
|
||||||
|
|
||||||
|
// Test updating 12-hour format with PM
|
||||||
|
const pmDate = setDateByType(date, '09', '12hours', 'PM');
|
||||||
|
expect(pmDate.getHours()).toBe(21);
|
||||||
|
|
||||||
|
// Test 12 AM (midnight)
|
||||||
|
const midnightDate = setDateByType(date, '12', '12hours', 'AM');
|
||||||
|
expect(midnightDate.getHours()).toBe(0);
|
||||||
|
|
||||||
|
// Test 12 PM (noon)
|
||||||
|
const noonDate = setDateByType(date, '12', '12hours', 'PM');
|
||||||
|
expect(noonDate.getHours()).toBe(12);
|
||||||
|
|
||||||
|
// Test with missing period for 12-hour format
|
||||||
|
const missingPeriodDate = setDateByType(date, '09', '12hours');
|
||||||
|
expect(missingPeriodDate).toBe(date); // Should return original date unchanged
|
||||||
|
|
||||||
|
// Test with invalid picker type
|
||||||
|
const invalidTypeDate = setDateByType(
|
||||||
|
date,
|
||||||
|
'14',
|
||||||
|
'invalid' as TimePickerType,
|
||||||
|
);
|
||||||
|
expect(invalidTypeDate).toBe(date); // Should return original date unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
244
dashboard/src/components/common/TimePicker/time-picker-utils.ts
Normal file
244
dashboard/src/components/common/TimePicker/time-picker-utils.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { TZDate } from '@date-fns/tz';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* regular expression to check for valid hour format (01-23)
|
||||||
|
*/
|
||||||
|
export function isValidHour(value: string) {
|
||||||
|
return /^(0[0-9]|1[0-9]|2[0-3])$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* regular expression to check for valid 12 hour format (01-12)
|
||||||
|
*/
|
||||||
|
export function isValid12Hour(value: string) {
|
||||||
|
return /^(0[1-9]|1[0-2])$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* regular expression to check for valid minute format (00-59)
|
||||||
|
*/
|
||||||
|
export function isValidMinuteOrSecond(value: string) {
|
||||||
|
return /^[0-5][0-9]$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetValidNumberConfig = { max: number; min?: number; loop?: boolean };
|
||||||
|
|
||||||
|
export function getValidNumber(
|
||||||
|
value: string,
|
||||||
|
{ max, min = 0, loop = false }: GetValidNumberConfig,
|
||||||
|
) {
|
||||||
|
let numericValue = parseInt(value, 10);
|
||||||
|
|
||||||
|
if (!Number.isNaN(numericValue)) {
|
||||||
|
if (!loop) {
|
||||||
|
if (numericValue > max) {
|
||||||
|
numericValue = max;
|
||||||
|
}
|
||||||
|
if (numericValue < min) {
|
||||||
|
numericValue = min;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (numericValue > max) {
|
||||||
|
numericValue = min;
|
||||||
|
}
|
||||||
|
if (numericValue < min) {
|
||||||
|
numericValue = max;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return numericValue.toString().padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return '00';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidHour(value: string) {
|
||||||
|
if (isValidHour(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return getValidNumber(value, { max: 23 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValid12Hour(value: string) {
|
||||||
|
if (isValid12Hour(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return getValidNumber(value, { min: 1, max: 12 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidMinuteOrSecond(value: string) {
|
||||||
|
if (isValidMinuteOrSecond(value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return getValidNumber(value, { max: 59 });
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetValidArrowNumberConfig = {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getValidArrowNumber(
|
||||||
|
value: string,
|
||||||
|
{ min, max, step }: GetValidArrowNumberConfig,
|
||||||
|
) {
|
||||||
|
let numericValue = parseInt(value, 10);
|
||||||
|
if (!Number.isNaN(numericValue)) {
|
||||||
|
numericValue += step;
|
||||||
|
return getValidNumber(String(numericValue), { min, max, loop: true });
|
||||||
|
}
|
||||||
|
return '00';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidArrowHour(value: string, step: number) {
|
||||||
|
return getValidArrowNumber(value, { min: 0, max: 23, step });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidArrow12Hour(value: string, step: number) {
|
||||||
|
return getValidArrowNumber(value, { min: 1, max: 12, step });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getValidArrowMinuteOrSecond(value: string, step: number) {
|
||||||
|
return getValidArrowNumber(value, { min: 0, max: 59, step });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMinutes(date: Date, value: string) {
|
||||||
|
const minutes = getValidMinuteOrSecond(value);
|
||||||
|
date.setMinutes(parseInt(minutes, 10));
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSeconds(date: Date, value: string) {
|
||||||
|
const seconds = getValidMinuteOrSecond(value);
|
||||||
|
date.setSeconds(parseInt(seconds, 10));
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHours(date: Date, value: string) {
|
||||||
|
const hours = getValidHour(value);
|
||||||
|
date.setHours(parseInt(hours, 10));
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* handles value change of 12-hour input
|
||||||
|
* 12:00 PM is 12:00
|
||||||
|
* 12:00 AM is 00:00
|
||||||
|
*/
|
||||||
|
export function convert12HourTo24Hour(hour: number, period: Period) {
|
||||||
|
if (period === 'PM') {
|
||||||
|
if (hour <= 11) {
|
||||||
|
return hour + 12;
|
||||||
|
}
|
||||||
|
return hour;
|
||||||
|
}
|
||||||
|
if (period === 'AM') {
|
||||||
|
if (hour === 12) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return hour;
|
||||||
|
}
|
||||||
|
return hour;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function set12Hours(date: Date, value: string, period: Period) {
|
||||||
|
const hours = parseInt(getValid12Hour(value), 10);
|
||||||
|
const convertedHours = convert12HourTo24Hour(hours, period);
|
||||||
|
date.setHours(convertedHours);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TimePickerType = 'minutes' | 'seconds' | 'hours' | '12hours';
|
||||||
|
export type Period = 'AM' | 'PM';
|
||||||
|
|
||||||
|
export function setDateByType(
|
||||||
|
date: Date,
|
||||||
|
value: string,
|
||||||
|
type: TimePickerType,
|
||||||
|
period?: Period,
|
||||||
|
) {
|
||||||
|
switch (type) {
|
||||||
|
case 'minutes':
|
||||||
|
return setMinutes(date, value);
|
||||||
|
case 'seconds':
|
||||||
|
return setSeconds(date, value);
|
||||||
|
case 'hours':
|
||||||
|
return setHours(date, value);
|
||||||
|
case '12hours': {
|
||||||
|
if (!period) {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
return set12Hours(date, value, period);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* time is stored in the 24-hour form,
|
||||||
|
* but needs to be displayed to the user
|
||||||
|
* in its 12-hour representation
|
||||||
|
*/
|
||||||
|
export function display12HourValue(hours: number) {
|
||||||
|
if (hours === 0 || hours === 12) {
|
||||||
|
return '12';
|
||||||
|
}
|
||||||
|
if (hours >= 22) {
|
||||||
|
return `${hours - 12}`;
|
||||||
|
}
|
||||||
|
if (hours % 12 > 9) {
|
||||||
|
return `${hours}`;
|
||||||
|
}
|
||||||
|
return `0${hours % 12}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDateByType(date: Date, type: TimePickerType) {
|
||||||
|
switch (type) {
|
||||||
|
case 'minutes':
|
||||||
|
return getValidMinuteOrSecond(String(date.getMinutes()));
|
||||||
|
case 'seconds':
|
||||||
|
return getValidMinuteOrSecond(String(date.getSeconds()));
|
||||||
|
case 'hours':
|
||||||
|
return getValidHour(String(date.getHours()));
|
||||||
|
case '12hours': {
|
||||||
|
const hours = display12HourValue(date.getHours());
|
||||||
|
return getValid12Hour(String(hours));
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return '00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArrowByType(
|
||||||
|
value: string,
|
||||||
|
step: number,
|
||||||
|
type: TimePickerType,
|
||||||
|
) {
|
||||||
|
switch (type) {
|
||||||
|
case 'minutes':
|
||||||
|
return getValidArrowMinuteOrSecond(value, step);
|
||||||
|
case 'seconds':
|
||||||
|
return getValidArrowMinuteOrSecond(value, step);
|
||||||
|
case 'hours':
|
||||||
|
return getValidArrowHour(value, step);
|
||||||
|
case '12hours':
|
||||||
|
return getValidArrow12Hour(value, step);
|
||||||
|
default:
|
||||||
|
return '00';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTZDate(date: Date | TZDate): date is TZDate {
|
||||||
|
return date instanceof TZDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function copyDate(date: Date | TZDate) {
|
||||||
|
if (isTZDate(date)) {
|
||||||
|
const { timeZone } = date;
|
||||||
|
const dateTime = date.toISOString();
|
||||||
|
return new TZDate(dateTime, timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { VirtualizedCombobox } from '@/components/common/VirtualizedCombobox';
|
||||||
|
import { createTimezoneOptions } from '@/utils/timezoneUtils';
|
||||||
|
import { memo, useMemo } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
selectedTimezone: string;
|
||||||
|
onTimezoneSelect: (timezone: { value: string; label: string }) => void;
|
||||||
|
button?: React.JSX.Element;
|
||||||
|
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({
|
||||||
|
selectedTimezone,
|
||||||
|
onTimezoneSelect,
|
||||||
|
button,
|
||||||
|
dateTime,
|
||||||
|
}: Props) {
|
||||||
|
const timezoneOptions = useMemo(
|
||||||
|
() => getOrderedTimezones(dateTime, selectedTimezone),
|
||||||
|
[dateTime, selectedTimezone],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualizedCombobox
|
||||||
|
options={timezoneOptions}
|
||||||
|
selectedOption={selectedTimezone}
|
||||||
|
onSelectOption={onTimezoneSelect}
|
||||||
|
searchPlaceholder="Search timezones..."
|
||||||
|
button={button}
|
||||||
|
side="right"
|
||||||
|
width="370px"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(TimezonePicker);
|
||||||
1
dashboard/src/components/common/TimezonePicker/index.ts
Normal file
1
dashboard/src/components/common/TimezonePicker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default as TimezonePicker } from './TimezonePicker';
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { useDialog } from '@/components/common/DialogProvider';
|
|
||||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||||
import { Box } from '@/components/ui/v2/Box';
|
import { Box } from '@/components/ui/v2/Box';
|
||||||
import { Button } from '@/components/ui/v2/Button';
|
|
||||||
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 { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
|
|
||||||
@@ -21,11 +20,11 @@ export default function UpgradeToProBanner({
|
|||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
}: UpgradeToProBannerProps) {
|
}: UpgradeToProBannerProps) {
|
||||||
const isOwner = useIsCurrentUserOwner();
|
|
||||||
const { openAlertDialog } = useDialog();
|
|
||||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
|
||||||
|
const handleTransferDialogOpen = () => setTransferProjectDialogOpen(true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
sx={{ backgroundColor: 'primary.light' }}
|
sx={{ backgroundColor: 'primary.light' }}
|
||||||
@@ -51,29 +50,7 @@ export default function UpgradeToProBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
||||||
<Button
|
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||||
className="max-w-xs lg:w-auto"
|
|
||||||
onClick={() => {
|
|
||||||
if (isOwner) {
|
|
||||||
setTransferProjectDialogOpen(true);
|
|
||||||
} else {
|
|
||||||
openAlertDialog({
|
|
||||||
title: "You can't migrate this project",
|
|
||||||
payload: (
|
|
||||||
<Text variant="subtitle1" component="span">
|
|
||||||
Ask an owner of this organization to migrate the project.
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
props: {
|
|
||||||
secondaryButtonText: 'I understand',
|
|
||||||
hidePrimaryAction: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Transfer Project
|
|
||||||
</Button>
|
|
||||||
<TransferProjectDialog
|
<TransferProjectDialog
|
||||||
open={transferProjectDialogOpen}
|
open={transferProjectDialogOpen}
|
||||||
setOpen={setTransferProjectDialogOpen}
|
setOpen={setTransferProjectDialogOpen}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from '@/components/ui/v3/command';
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from '@/components/ui/v3/popover';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
type Option = {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
key?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface VirtualizedCommandProps<O extends Option> {
|
||||||
|
height: string;
|
||||||
|
options: O[];
|
||||||
|
placeholder: string;
|
||||||
|
selectedOption: string;
|
||||||
|
onSelectOption?: (option: O) => void;
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VirtualizedCommand<O extends Option>({
|
||||||
|
height,
|
||||||
|
options,
|
||||||
|
placeholder,
|
||||||
|
selectedOption,
|
||||||
|
onSelectOption,
|
||||||
|
emptyText,
|
||||||
|
}: VirtualizedCommandProps<O>) {
|
||||||
|
const [filteredOptions, setFilteredOptions] = React.useState<O[]>(options);
|
||||||
|
const [focusedIndex, setFocusedIndex] = React.useState(0);
|
||||||
|
const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false);
|
||||||
|
|
||||||
|
const parentRef = React.useRef(null);
|
||||||
|
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: filteredOptions.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 35,
|
||||||
|
});
|
||||||
|
|
||||||
|
const virtualOptions = virtualizer.getVirtualItems();
|
||||||
|
|
||||||
|
const scrollToIndex = (index: number) => {
|
||||||
|
virtualizer.scrollToIndex(index, {
|
||||||
|
align: 'center',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (search: string) => {
|
||||||
|
setIsKeyboardNavActive(false);
|
||||||
|
setFilteredOptions(
|
||||||
|
options.filter((option) =>
|
||||||
|
option.label.toLowerCase().includes(search.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown': {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsKeyboardNavActive(true);
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const newIndex =
|
||||||
|
prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1);
|
||||||
|
scrollToIndex(newIndex);
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ArrowUp': {
|
||||||
|
event.preventDefault();
|
||||||
|
setIsKeyboardNavActive(true);
|
||||||
|
setFocusedIndex((prev) => {
|
||||||
|
const newIndex =
|
||||||
|
prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0);
|
||||||
|
scrollToIndex(newIndex);
|
||||||
|
return newIndex;
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'Enter': {
|
||||||
|
event.preventDefault();
|
||||||
|
if (filteredOptions[focusedIndex]) {
|
||||||
|
onSelectOption?.(filteredOptions[focusedIndex]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
value={selectedOption}
|
||||||
|
>
|
||||||
|
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
||||||
|
<CommandList
|
||||||
|
ref={parentRef}
|
||||||
|
style={{
|
||||||
|
height,
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
onMouseDown={() => setIsKeyboardNavActive(false)}
|
||||||
|
onMouseMove={() => setIsKeyboardNavActive(false)}
|
||||||
|
>
|
||||||
|
<CommandEmpty>{emptyText || 'No item found.'}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: `${virtualizer.getTotalSize()}px`,
|
||||||
|
width: '100%',
|
||||||
|
position: 'relative',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualOptions.map((virtualOption) => (
|
||||||
|
<CommandItem
|
||||||
|
key={
|
||||||
|
filteredOptions[virtualOption.index].key ??
|
||||||
|
filteredOptions[virtualOption.index].value
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'absolute left-0 top-0 w-full bg-transparent',
|
||||||
|
focusedIndex === virtualOption.index &&
|
||||||
|
'bg-accent text-accent-foreground',
|
||||||
|
isKeyboardNavActive &&
|
||||||
|
focusedIndex !== virtualOption.index &&
|
||||||
|
'aria-selected:bg-transparent aria-selected:text-primary',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
height: `${virtualOption.size}px`,
|
||||||
|
transform: `translateY(${virtualOption.start}px)`,
|
||||||
|
}}
|
||||||
|
value={filteredOptions[virtualOption.index].value}
|
||||||
|
onMouseEnter={() =>
|
||||||
|
!isKeyboardNavActive && setFocusedIndex(virtualOption.index)
|
||||||
|
}
|
||||||
|
onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)}
|
||||||
|
onSelect={() => onSelectOption?.(filteredOptions[focusedIndex])}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
'mr-2 h-4 w-4',
|
||||||
|
selectedOption ===
|
||||||
|
filteredOptions[virtualOption.index].value
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{filteredOptions[virtualOption.index].label}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VirtualizedComboboxProps<O extends Option> {
|
||||||
|
options: O[];
|
||||||
|
searchPlaceholder?: string;
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
button?: React.JSX.Element;
|
||||||
|
onSelectOption?: (option: O) => void;
|
||||||
|
selectedOption: string;
|
||||||
|
align?: 'start' | 'center' | 'end';
|
||||||
|
side?: 'right' | 'top' | 'bottom' | 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
function VirtualizedCombobox<O extends Option>({
|
||||||
|
options,
|
||||||
|
searchPlaceholder = 'Search items...',
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
button,
|
||||||
|
onSelectOption,
|
||||||
|
selectedOption,
|
||||||
|
align = 'start',
|
||||||
|
side,
|
||||||
|
}: VirtualizedComboboxProps<O>) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const defaultButton = (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="justify-between"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selectedOption
|
||||||
|
? options.find((option) => option.value === selectedOption).value
|
||||||
|
: searchPlaceholder}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>{button || defaultButton}</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className="p-0"
|
||||||
|
style={{ width }}
|
||||||
|
align={align}
|
||||||
|
side={side}
|
||||||
|
>
|
||||||
|
<VirtualizedCommand
|
||||||
|
height={height}
|
||||||
|
options={options}
|
||||||
|
placeholder={searchPlaceholder}
|
||||||
|
selectedOption={selectedOption}
|
||||||
|
onSelectOption={(currentValue) => {
|
||||||
|
onSelectOption(currentValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VirtualizedCombobox;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as VirtualizedCombobox } from './VirtualizedCombobox';
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
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>;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export * from './DataGrid';
|
|
||||||
export { default as DataGrid } from './DataGrid';
|
|
||||||
export * from './useDataGrid';
|
|
||||||
export { default as useDataGrid } from './useDataGrid';
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridBody';
|
|
||||||
export { default as DataGridBody } from './DataGridBody';
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
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} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridBooleanCell';
|
|
||||||
export { default as DataGridBooleanCell } from './DataGridBooleanCell';
|
|
||||||
@@ -1,381 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export * from './DataGridCell';
|
|
||||||
export { default as DataGridCell } from './DataGridCell';
|
|
||||||
export * from './DataGridCellProvider';
|
|
||||||
export { default as DataGridCellProvider } from './DataGridCellProvider';
|
|
||||||
export { default as useDataGridCell } from './useDataGridCell';
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
|
||||||
import { createContext } from 'react';
|
|
||||||
|
|
||||||
const DataGridConfigContext = createContext<Partial<UseDataGridReturn>>(null);
|
|
||||||
|
|
||||||
export default DataGridConfigContext;
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as DataGridConfigContext } from './DataGridConfigContext';
|
|
||||||
export { default as DataGridConfigProvider } from './DataGridConfigProvider';
|
|
||||||
export { default as useDataGridConfig } from './useDataGridConfig';
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridDateCell';
|
|
||||||
export { default as DataGridDateCell } from './DataGridDateCell';
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridFrame';
|
|
||||||
export { default as DataGridFrame } from './DataGridFrame';
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridHeader';
|
|
||||||
export { default as DataGridHeader } from './DataGridHeader';
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
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>;
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridNumericCell';
|
|
||||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridPagination';
|
|
||||||
export { default as DataGridPagination } from './DataGridPagination';
|
|
||||||
@@ -1,410 +0,0 @@
|
|||||||
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'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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridPreviewCell';
|
|
||||||
export { default as DataGridPreviewCell } from './DataGridPreviewCell';
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DataGridTextCell';
|
|
||||||
export { default as DataGridTextCell } from './DataGridTextCell';
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './AILayout';
|
|
||||||
export { default as SettingsLayout } from './AILayout';
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './AISidebar';
|
|
||||||
export { default as AISidebar } from './AISidebar';
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
@@ -10,14 +9,14 @@ import { RetryableErrorBoundary } from '@/components/presentational/RetryableErr
|
|||||||
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/projects/common/hooks/useIsPlatform';
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||||
|
|
||||||
import { useMediaQuery } from '@/components/common/useMediaQuery';
|
import { useMediaQuery } from '@/components/common/useMediaQuery';
|
||||||
import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
||||||
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/projects/common/hooks/useNotFoundRedirect';
|
import { useNotFoundRedirect } from '@/features/orgs/projects/common/hooks/useNotFoundRedirect';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
@@ -98,7 +97,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"
|
href="https://docs.nhost.io/platform/cli/local-development"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
underline="hover"
|
underline="hover"
|
||||||
@@ -146,8 +145,6 @@ export default function AuthenticatedLayout({
|
|||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</RetryableErrorBoundary>
|
</RetryableErrorBoundary>
|
||||||
|
|
||||||
<InviteNotification />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './Breadcrumbs';
|
|
||||||
export { default as Breadcrumbs } from './Breadcrumbs';
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './DesktopNav';
|
|
||||||
export { default as DesktopNav } from './DesktopNav';
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
|
||||||
Command,
|
|
||||||
CommandEmpty,
|
|
||||||
CommandGroup,
|
|
||||||
CommandInput,
|
|
||||||
CommandItem,
|
|
||||||
CommandList,
|
|
||||||
} from '@/components/ui/v3/command';
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/v3/popover';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
|
||||||
import { useState, type ReactNode } from 'react';
|
|
||||||
|
|
||||||
type BreadCrumbComboBoxItem<T> = {
|
|
||||||
label: string | ReactNode;
|
|
||||||
value: string | T;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface BreadCrumbComboBoxProps<T> {
|
|
||||||
selectedValue?: T;
|
|
||||||
options: BreadCrumbComboBoxItem<T>[];
|
|
||||||
renderItem?: (item: T) => ReactNode;
|
|
||||||
onChange?: (item: BreadCrumbComboBoxItem<T>) => void;
|
|
||||||
filter?: (value: string, search: string) => number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BreadCrumbComboBox<T>({
|
|
||||||
selectedValue,
|
|
||||||
options,
|
|
||||||
renderItem,
|
|
||||||
onChange,
|
|
||||||
filter,
|
|
||||||
}: BreadCrumbComboBoxProps<T>) {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [selectedItem, setSelectedItem] =
|
|
||||||
useState<BreadCrumbComboBoxItem<T> | null>(
|
|
||||||
options.find((option) => option.value === selectedValue) || null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderSelectedItem = (item: BreadCrumbComboBoxItem<T>) => {
|
|
||||||
if (typeof item.value === 'string') {
|
|
||||||
return typeof item.label === 'string' ? (
|
|
||||||
<span className="text-foreground">{item.label}</span>
|
|
||||||
) : (
|
|
||||||
item.label
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return renderItem ? renderItem(item.value) : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="justify-start text-foreground"
|
|
||||||
>
|
|
||||||
<div className="flex flex-row items-center justify-center gap-1">
|
|
||||||
{selectedItem && renderSelectedItem(selectedItem)}
|
|
||||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="p-0" side="bottom" align="start">
|
|
||||||
<Command filter={filter}>
|
|
||||||
<CommandInput placeholder="Search..." autoFocus />
|
|
||||||
<CommandList>
|
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{options.map((option, index) => (
|
|
||||||
<CommandItem
|
|
||||||
key={`${
|
|
||||||
typeof option.value === 'string' ? option.value : index
|
|
||||||
}`}
|
|
||||||
value={
|
|
||||||
typeof option.value === 'string' ? option.value : `${index}`
|
|
||||||
}
|
|
||||||
onSelect={() => {
|
|
||||||
setSelectedItem(option);
|
|
||||||
setOpen(false);
|
|
||||||
onChange?.(option);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
'mr-2 h-4 w-4',
|
|
||||||
selectedItem?.value === option.value
|
|
||||||
? 'opacity-100'
|
|
||||||
: 'opacity-0',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{typeof option.value === 'string'
|
|
||||||
? option.label
|
|
||||||
: renderItem && renderItem(option.value)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -17,8 +17,7 @@ import ProjectSettingsPagesComboBox from './ProjectSettingsPagesComboBox';
|
|||||||
export default function BreadcrumbNav() {
|
export default function BreadcrumbNav() {
|
||||||
const { query, asPath, route } = useRouter();
|
const { query, asPath, route } = useRouter();
|
||||||
|
|
||||||
// Extract orgSlug and appSubdomain from router.query
|
const { appSubdomain } = query;
|
||||||
const { appSubdomain, workspaceSlug } = query;
|
|
||||||
|
|
||||||
// Extract path segments from the URL
|
// Extract path segments from the URL
|
||||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||||
@@ -27,8 +26,7 @@ export default function BreadcrumbNav() {
|
|||||||
const projectPage = pathSegments[3] || null;
|
const projectPage = pathSegments[3] || null;
|
||||||
const isSettingsPage = pathSegments[5] === 'settings';
|
const isSettingsPage = pathSegments[5] === 'settings';
|
||||||
|
|
||||||
const showBreadcrumbs =
|
const showBreadcrumbs = !['/', '/orgs/verify'].includes(route);
|
||||||
!workspaceSlug && !['/', '/orgs/verify'].includes(route);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Breadcrumb className="mt-2 flex w-full flex-row flex-nowrap overflow-x-auto lg:mt-0 lg:overflow-visible">
|
<Breadcrumb className="mt-2 flex w-full flex-row flex-nowrap overflow-x-auto lg:mt-0 lg:overflow-visible">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user