Compare commits
32 Commits
@nhost/rea
...
@nhost/rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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'
|
||||||
|
|||||||
30
.github/workflows/ci.yaml
vendored
30
.github/workflows/ci.yaml
vendored
@@ -134,10 +134,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 +174,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/
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
# Nhost
|
# Nhost
|
||||||
|
|
||||||
<a href="https://docs.nhost.io/#quickstart">Quickstart</a>
|
<a href="https://docs.nhost.io/introduction#quick-start-guides">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/development/cli/overview) for local development
|
||||||
|
|
||||||
## Architecture of Nhost
|
## Architecture of Nhost
|
||||||
|
|
||||||
@@ -89,12 +89,12 @@ 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/guides/quickstarts/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"><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/guides/quickstarts/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/reference/javascript"><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"><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/guides/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Resources
|
# Resources
|
||||||
@@ -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/introduction)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||||
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
|
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
|
||||||
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!
|
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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/ .
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
43
dashboard/e2e/auth/edit-user.test.ts
Normal file
43
dashboard/e2e/auth/edit-user.test.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
|
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
page = await browser.newPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||||
|
await page.goto(authUrl);
|
||||||
|
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to edit user roles from the details page', async () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
149
dashboard/e2e/database/permissions-table.test.ts
Normal file
149
dashboard/e2e/database/permissions-table.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
|
import {
|
||||||
|
clickPermissionButton,
|
||||||
|
navigateToProject,
|
||||||
|
prepareTable,
|
||||||
|
} from '@/e2e/utils';
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import type { Page } from '@playwright/test';
|
||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
import { snakeCase } from 'snake-case';
|
||||||
|
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
test.beforeAll(async ({ browser }) => {
|
||||||
|
page = await browser.newPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.beforeEach(async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await navigateToProject({
|
||||||
|
page,
|
||||||
|
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||||
|
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
|
await page.goto(databaseRoute);
|
||||||
|
await page.waitForURL(databaseRoute);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should create a table with role permissions to select row', async () => {
|
||||||
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
|
await 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 () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
61
dashboard/e2e/teardown/database.teardown.ts
Normal file
61
dashboard/e2e/teardown/database.teardown.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import {
|
||||||
|
TEST_DASHBOARD_URL,
|
||||||
|
TEST_ORGANIZATION_SLUG,
|
||||||
|
TEST_PROJECT_SUBDOMAIN,
|
||||||
|
} from '@/e2e/env';
|
||||||
|
import { navigateToProject } from '@/e2e/utils';
|
||||||
|
import { type Page, expect, test as teardown } from '@playwright/test';
|
||||||
|
|
||||||
|
let page: Page;
|
||||||
|
|
||||||
|
teardown.beforeAll(async ({ browser }) => {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: TEST_DASHBOARD_URL,
|
||||||
|
storageState: 'e2e/.auth/user.json',
|
||||||
|
});
|
||||||
|
|
||||||
|
page = await context.newPage();
|
||||||
|
});
|
||||||
|
|
||||||
|
teardown.beforeEach(async () => {
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
await navigateToProject({
|
||||||
|
page,
|
||||||
|
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||||
|
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
|
await page.goto(databaseRoute);
|
||||||
|
await page.waitForURL(databaseRoute);
|
||||||
|
});
|
||||||
|
|
||||||
|
teardown.afterAll(async () => {
|
||||||
|
await page.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
teardown('clean up database tables', async () => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -191,3 +191,23 @@ 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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
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',
|
||||||
|
value: 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.22.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",
|
||||||
@@ -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": "8.10.1",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.0.13",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
@@ -113,6 +117,7 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
'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;
|
||||||
|
}
|
||||||
|
// in: UTC datetime
|
||||||
|
// out: UTC dateTime
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
setDate(newDateWithTimezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(newOpenState: boolean) {
|
||||||
|
if (!newOpenState) {
|
||||||
|
if (withTimezone) {
|
||||||
|
const tz = defaultTimezone || guessTimezone();
|
||||||
|
setDate(new TZDate(dateTime, tz));
|
||||||
|
}
|
||||||
|
setDate(parseISO(dateTime));
|
||||||
|
}
|
||||||
|
setOpen(newOpenState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect() {
|
||||||
|
emitNewDateTime();
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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}
|
||||||
|
onSelect={(d) => handleSelect(d)}
|
||||||
|
initialFocus
|
||||||
|
disabled={isCalendarDayDisabled}
|
||||||
|
/>
|
||||||
|
<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={dateTime}
|
||||||
|
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,39 @@
|
|||||||
|
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">
|
||||||
|
Timezone: {utcOffset}{' '}
|
||||||
|
<TimezonePicker
|
||||||
|
dateTime={dateTime}
|
||||||
|
selectedTimezone={selectedTimezone}
|
||||||
|
onTimezoneSelect={handleTimezoneSelect}
|
||||||
|
button={
|
||||||
|
<Button variant="ghost" size="icon">
|
||||||
|
<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';
|
||||||
@@ -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';
|
||||||
105
dashboard/src/components/common/TimePicker/TimePicker.test.tsx
Normal file
105
dashboard/src/components/common/TimePicker/TimePicker.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { render, screen } from '@/tests/orgs/testUtils';
|
||||||
|
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||||
|
import { TZDate } from '@date-fns/tz';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
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 = userEvent.setup();
|
||||||
|
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 = userEvent.setup();
|
||||||
|
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 = userEvent.setup();
|
||||||
|
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 = userEvent.setup();
|
||||||
|
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 = userEvent.setup();
|
||||||
|
|
||||||
|
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 {
|
||||||
|
type Period,
|
||||||
|
type TimePickerType,
|
||||||
|
copyDate,
|
||||||
|
getArrowByType,
|
||||||
|
getDateByType,
|
||||||
|
setDateByType,
|
||||||
|
} 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,34 @@
|
|||||||
|
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 TimezonePicker({
|
||||||
|
selectedTimezone,
|
||||||
|
onTimezoneSelect,
|
||||||
|
button,
|
||||||
|
dateTime,
|
||||||
|
}: Props) {
|
||||||
|
const timezoneOptions = useMemo(
|
||||||
|
() => createTimezoneOptions(dateTime),
|
||||||
|
[dateTime],
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<VirtualizedCombobox
|
||||||
|
options={timezoneOptions}
|
||||||
|
selectedOption={selectedTimezone}
|
||||||
|
onSelectOption={onTimezoneSelect}
|
||||||
|
searchPlaceholder="Search timezones..."
|
||||||
|
button={button}
|
||||||
|
side="right"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,252 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedOption) {
|
||||||
|
const option = filteredOptions.find(
|
||||||
|
(opt) => opt.value === selectedOption,
|
||||||
|
);
|
||||||
|
if (option) {
|
||||||
|
const index = filteredOptions.indexOf(option);
|
||||||
|
setFocusedIndex(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [selectedOption, filteredOptions, virtualizer]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
||||||
|
<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
|
||||||
|
}
|
||||||
|
disabled={isKeyboardNavActive}
|
||||||
|
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';
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/v3/popover';
|
} from '@/components/ui/v3/popover';
|
||||||
|
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ export default function OrgPagesComboBox() {
|
|||||||
asPath,
|
asPath,
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
|
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
|
||||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||||
const orgPageFromUrl = pathSegments[3] || null;
|
const orgPageFromUrl = pathSegments[3] || null;
|
||||||
|
|
||||||
@@ -64,7 +67,7 @@ export default function OrgPagesComboBox() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger disabled={!isPlatform} asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/v3/popover';
|
} from '@/components/ui/v3/popover';
|
||||||
|
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||||
@@ -40,88 +41,10 @@ type Option = {
|
|||||||
value: string;
|
value: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: ReactElement;
|
icon: ReactElement;
|
||||||
|
disabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectPages = [
|
type SelectedOption = Omit<Option, 'disabled'>;
|
||||||
{
|
|
||||||
label: 'Overview',
|
|
||||||
value: 'overview',
|
|
||||||
icon: <HomeIcon className="h-4 w-4" />,
|
|
||||||
slug: '',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Database',
|
|
||||||
value: 'database',
|
|
||||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
|
||||||
slug: '/database/browser/default',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'GraphQL',
|
|
||||||
value: 'graphql',
|
|
||||||
icon: <GraphQLIcon className="h-4 w-4" />,
|
|
||||||
slug: 'graphql',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Hasura',
|
|
||||||
value: 'hasura',
|
|
||||||
icon: <HasuraIcon className="h-4 w-4" />,
|
|
||||||
slug: 'hasura',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Auth',
|
|
||||||
value: 'users',
|
|
||||||
icon: <UserIcon className="h-4 w-4" />,
|
|
||||||
slug: 'users',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Storage',
|
|
||||||
value: 'storage',
|
|
||||||
icon: <StorageIcon className="h-4 w-4" />,
|
|
||||||
slug: 'storage',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Run',
|
|
||||||
value: 'run',
|
|
||||||
icon: <ServicesIcon className="h-4 w-4" />,
|
|
||||||
slug: 'run',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'AI',
|
|
||||||
value: 'ai',
|
|
||||||
icon: <AIIcon className="h-4 w-4" />,
|
|
||||||
slug: 'ai/auto-embeddings',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Deployments',
|
|
||||||
value: 'deployments',
|
|
||||||
icon: <RocketIcon className="h-4 w-4" />,
|
|
||||||
slug: 'deployments',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Backups',
|
|
||||||
value: 'backups',
|
|
||||||
icon: <CloudIcon className="h-4 w-4" />,
|
|
||||||
slug: 'backups',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Logs',
|
|
||||||
value: 'logs',
|
|
||||||
icon: <FileTextIcon className="h-4 w-4" />,
|
|
||||||
slug: 'logs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Metrics',
|
|
||||||
value: 'metrics',
|
|
||||||
icon: <GaugeIcon className="h-4 w-4" />,
|
|
||||||
slug: 'metrics',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Settings',
|
|
||||||
value: 'settings',
|
|
||||||
icon: <CogIcon className="h-4 w-4" />,
|
|
||||||
slug: 'settings',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function ProjectPagesComboBox() {
|
export default function ProjectPagesComboBox() {
|
||||||
const {
|
const {
|
||||||
@@ -130,6 +53,105 @@ export default function ProjectPagesComboBox() {
|
|||||||
asPath,
|
asPath,
|
||||||
} = useRouter();
|
} = useRouter();
|
||||||
|
|
||||||
|
const isPlatform = useIsPlatform();
|
||||||
|
|
||||||
|
const projectPages = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
value: 'overview',
|
||||||
|
icon: <HomeIcon className="h-4 w-4" />,
|
||||||
|
slug: '',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Database',
|
||||||
|
value: 'database',
|
||||||
|
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||||
|
slug: '/database/browser/default',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'GraphQL',
|
||||||
|
value: 'graphql',
|
||||||
|
icon: <GraphQLIcon className="h-4 w-4" />,
|
||||||
|
slug: 'graphql',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Hasura',
|
||||||
|
value: 'hasura',
|
||||||
|
icon: <HasuraIcon className="h-4 w-4" />,
|
||||||
|
slug: 'hasura',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Auth',
|
||||||
|
value: 'users',
|
||||||
|
icon: <UserIcon className="h-4 w-4" />,
|
||||||
|
slug: 'users',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Storage',
|
||||||
|
value: 'storage',
|
||||||
|
icon: <StorageIcon className="h-4 w-4" />,
|
||||||
|
slug: 'storage',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Run',
|
||||||
|
value: 'run',
|
||||||
|
icon: <ServicesIcon className="h-4 w-4" />,
|
||||||
|
slug: 'run',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'AI',
|
||||||
|
value: 'ai',
|
||||||
|
icon: <AIIcon className="h-4 w-4" />,
|
||||||
|
slug: 'ai/auto-embeddings',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Deployments',
|
||||||
|
value: 'deployments',
|
||||||
|
icon: <RocketIcon className="h-4 w-4" />,
|
||||||
|
slug: 'deployments',
|
||||||
|
disabled: !isPlatform,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Backups',
|
||||||
|
value: 'backups',
|
||||||
|
icon: <CloudIcon className="h-4 w-4" />,
|
||||||
|
slug: 'backups',
|
||||||
|
disabled: !isPlatform,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Logs',
|
||||||
|
value: 'logs',
|
||||||
|
icon: <FileTextIcon className="h-4 w-4" />,
|
||||||
|
slug: 'logs',
|
||||||
|
disabled: !isPlatform,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Metrics',
|
||||||
|
value: 'metrics',
|
||||||
|
icon: <GaugeIcon className="h-4 w-4" />,
|
||||||
|
slug: 'metrics',
|
||||||
|
disabled: !isPlatform,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings',
|
||||||
|
value: 'settings',
|
||||||
|
icon: <CogIcon className="h-4 w-4" />,
|
||||||
|
slug: 'settings',
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[isPlatform],
|
||||||
|
);
|
||||||
|
|
||||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||||
const projectPageFromUrl = appSubdomain
|
const projectPageFromUrl = appSubdomain
|
||||||
? pathSegments[5] || 'overview'
|
? pathSegments[5] || 'overview'
|
||||||
@@ -137,9 +159,8 @@ export default function ProjectPagesComboBox() {
|
|||||||
const selectedProjectPageFromUrl = projectPages.find(
|
const selectedProjectPageFromUrl = projectPages.find(
|
||||||
(item) => item.value === projectPageFromUrl,
|
(item) => item.value === projectPageFromUrl,
|
||||||
);
|
);
|
||||||
const [selectedProjectPage, setSelectedProjectPage] = useState<Option | null>(
|
const [selectedProjectPage, setSelectedProjectPage] =
|
||||||
null,
|
useState<SelectedOption | null>(null);
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedProjectPageFromUrl) {
|
if (selectedProjectPageFromUrl) {
|
||||||
@@ -155,6 +176,7 @@ export default function ProjectPagesComboBox() {
|
|||||||
label: app.label,
|
label: app.label,
|
||||||
value: app.slug,
|
value: app.slug,
|
||||||
icon: app.icon,
|
icon: app.icon,
|
||||||
|
disabled: app.disabled,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -188,6 +210,7 @@ export default function ProjectPagesComboBox() {
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
value={option.label}
|
value={option.label}
|
||||||
|
disabled={option.disabled}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
setSelectedProjectPage(option);
|
setSelectedProjectPage(option);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||||
@@ -53,4 +54,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
);
|
);
|
||||||
Button.displayName = 'Button';
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
export { Button, buttonVariants };
|
const ButtonWithLoading = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ButtonProps & { loading?: boolean }
|
||||||
|
>(({ loading, disabled, children, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<Button disabled={loading || disabled} ref={ref} {...props}>
|
||||||
|
{loading && <Loader2 className="mr-2 animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { Button, buttonVariants, ButtonWithLoading };
|
||||||
|
|||||||
80
dashboard/src/components/ui/v3/calendar.tsx
Normal file
80
dashboard/src/components/ui/v3/calendar.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DayPicker,
|
||||||
|
type DayPickerProps,
|
||||||
|
type StyledComponent,
|
||||||
|
} from 'react-day-picker';
|
||||||
|
|
||||||
|
import { buttonVariants } from '@/components/ui/v3/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const IconLeft = ({ className, ...props }: StyledComponent) => (
|
||||||
|
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
|
||||||
|
);
|
||||||
|
const IconRight = ({ className, ...props }: StyledComponent) => (
|
||||||
|
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
|
||||||
|
);
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
...props
|
||||||
|
}: DayPickerProps) {
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn('p-3', className)}
|
||||||
|
classNames={{
|
||||||
|
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
||||||
|
month: 'space-y-4',
|
||||||
|
caption: 'flex justify-center pt-1 relative items-center',
|
||||||
|
caption_label: 'text-sm font-medium',
|
||||||
|
nav: 'space-x-1 flex items-center',
|
||||||
|
nav_button: cn(
|
||||||
|
buttonVariants({ variant: 'outline' }),
|
||||||
|
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||||
|
),
|
||||||
|
nav_button_previous: 'absolute left-1',
|
||||||
|
nav_button_next: 'absolute right-1',
|
||||||
|
table: 'w-full border-collapse space-y-1',
|
||||||
|
head_row: 'flex',
|
||||||
|
head_cell:
|
||||||
|
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
||||||
|
row: 'flex w-full mt-2',
|
||||||
|
cell: cn(
|
||||||
|
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
|
||||||
|
props.mode === 'range'
|
||||||
|
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
|
||||||
|
: '[&:has([aria-selected])]:rounded-md',
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
buttonVariants({ variant: 'ghost' }),
|
||||||
|
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
|
||||||
|
),
|
||||||
|
day_range_start: 'day-range-start',
|
||||||
|
day_range_end: 'day-range-end',
|
||||||
|
day_selected:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
||||||
|
day_today: 'bg-accent text-accent-foreground',
|
||||||
|
day_outside:
|
||||||
|
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
||||||
|
day_disabled: 'text-muted-foreground opacity-50',
|
||||||
|
day_range_middle:
|
||||||
|
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
||||||
|
day_hidden: 'invisible',
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
IconLeft,
|
||||||
|
IconRight,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Calendar.displayName = 'Calendar';
|
||||||
|
|
||||||
|
export { Calendar };
|
||||||
@@ -27,10 +27,15 @@ const DialogOverlay = React.forwardRef<
|
|||||||
));
|
));
|
||||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
interface DialogContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||||
|
disableOutsideClick?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
const DialogContent = React.forwardRef<
|
const DialogContent = React.forwardRef<
|
||||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
DialogContentProps
|
||||||
>(({ className, children, ...props }, ref) => (
|
>(({ className, children, disableOutsideClick, ...props }, ref) => (
|
||||||
<DialogPortal>
|
<DialogPortal>
|
||||||
<DialogOverlay>
|
<DialogOverlay>
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
@@ -39,6 +44,11 @@ const DialogContent = React.forwardRef<
|
|||||||
'relative z-50 grid w-full max-w-lg gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full',
|
'relative z-50 grid w-full max-w-lg gap-4 bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg md:w-full',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
onInteractOutside={
|
||||||
|
disableOutsideClick
|
||||||
|
? (e) => e.preventDefault()
|
||||||
|
: props.onInteractOutside
|
||||||
|
}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
56
dashboard/src/components/ui/v3/spinner.tsx
Normal file
56
dashboard/src/components/ui/v3/spinner.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { type VariantProps, cva } from 'class-variance-authority';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const spinnerVariants = cva('flex-col items-center justify-center', {
|
||||||
|
variants: {
|
||||||
|
show: {
|
||||||
|
true: 'flex',
|
||||||
|
false: 'hidden',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
show: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loaderVariants = cva('animate-spin text-primary', {
|
||||||
|
variants: {
|
||||||
|
size: {
|
||||||
|
small: 'size-6',
|
||||||
|
medium: 'size-8',
|
||||||
|
large: 'size-12',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
size: 'medium',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface SpinnerContentProps
|
||||||
|
extends VariantProps<typeof spinnerVariants>,
|
||||||
|
VariantProps<typeof loaderVariants> {
|
||||||
|
className?: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({
|
||||||
|
size,
|
||||||
|
show,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: SpinnerContentProps) {
|
||||||
|
return (
|
||||||
|
<span className={spinnerVariants({ show })}>
|
||||||
|
<Loader2
|
||||||
|
className={cn(
|
||||||
|
loaderVariants({ size }),
|
||||||
|
className,
|
||||||
|
'stroke-[#1e324b] dark:stroke-[#dfecf5]',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
dashboard/src/components/ui/v3/tabs.tsx
Normal file
53
dashboard/src/components/ui/v3/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||||
@@ -20,7 +20,7 @@ import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRo
|
|||||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import {
|
import {
|
||||||
RemoteAppGetUsersDocument,
|
RemoteAppGetUsersAndAuthRolesDocument,
|
||||||
useGetProjectLocalesQuery,
|
useGetProjectLocalesQuery,
|
||||||
useGetRolesPermissionsQuery,
|
useGetRolesPermissionsQuery,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
@@ -116,7 +116,7 @@ export default function EditUserForm({
|
|||||||
|
|
||||||
const [updateUser] = useUpdateRemoteAppUserMutation({
|
const [updateUser] = useUpdateRemoteAppUserMutation({
|
||||||
client: remoteProjectGQLClient,
|
client: remoteProjectGQLClient,
|
||||||
refetchQueries: [RemoteAppGetUsersDocument],
|
refetchQueries: [RemoteAppGetUsersAndAuthRolesDocument],
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<EditUserFormValues>({
|
const form = useForm<EditUserFormValues>({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/v2/Input';
|
|||||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||||
import {
|
import {
|
||||||
useGetSignInMethodsQuery,
|
useGetSignInMethodsQuery,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
@@ -26,7 +26,7 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
|||||||
/**
|
/**
|
||||||
* The selected user.
|
* The selected user.
|
||||||
*/
|
*/
|
||||||
user: RemoteAppGetUsersQuery['users'][0];
|
user: RemoteAppGetUsersAndAuthRolesQuery['users'][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditUserPasswordForm({
|
export default function EditUserPasswordForm({
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ query GetPostgresSettings($appId: uuid!) {
|
|||||||
}
|
}
|
||||||
enablePublicAccess
|
enablePublicAccess
|
||||||
}
|
}
|
||||||
|
pitr {
|
||||||
|
retention
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,23 @@ function CreateOrgForm({ plans, onSubmit, onCancel }: CreateOrgFormProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CreateOrgDialog() {
|
interface CreateOrgDialogProps {
|
||||||
|
hideNewOrgButton?: boolean;
|
||||||
|
isOpen?: boolean;
|
||||||
|
onOpenStateChange?: (newState: boolean) => void;
|
||||||
|
redirectUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPropSet(prop: any) {
|
||||||
|
return prop !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CreateOrgDialog({
|
||||||
|
hideNewOrgButton,
|
||||||
|
isOpen,
|
||||||
|
onOpenStateChange,
|
||||||
|
redirectUrl,
|
||||||
|
}: CreateOrgDialogProps) {
|
||||||
const { maintenanceActive } = useUI();
|
const { maintenanceActive } = useUI();
|
||||||
const user = useUserData();
|
const user = useUserData();
|
||||||
const isPlatform = useIsPlatform();
|
const isPlatform = useIsPlatform();
|
||||||
@@ -186,6 +202,16 @@ export default function CreateOrgDialog() {
|
|||||||
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
|
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
|
||||||
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
||||||
|
|
||||||
|
const handleOpenChange = (newOpenState: boolean) => {
|
||||||
|
const controlledFromOutSide =
|
||||||
|
isPropSet(isOpen) && isPropSet(onOpenStateChange);
|
||||||
|
if (controlledFromOutSide) {
|
||||||
|
onOpenStateChange(newOpenState);
|
||||||
|
} else {
|
||||||
|
setOpen(newOpenState);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const createOrg = async ({
|
const createOrg = async ({
|
||||||
name,
|
name,
|
||||||
plan,
|
plan,
|
||||||
@@ -195,16 +221,17 @@ export default function CreateOrgDialog() {
|
|||||||
}) => {
|
}) => {
|
||||||
await execPromiseWithErrorToast(
|
await execPromiseWithErrorToast(
|
||||||
async () => {
|
async () => {
|
||||||
|
const defaultRedirectUrl = `${window.location.origin}/orgs/verify`;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: { billingCreateOrganizationRequest: clientSecret },
|
data: { billingCreateOrganizationRequest: clientSecret },
|
||||||
} = await createOrganizationRequest({
|
} = await createOrganizationRequest({
|
||||||
variables: {
|
variables: {
|
||||||
organizationName: name,
|
organizationName: name,
|
||||||
planID: plan,
|
planID: plan,
|
||||||
redirectURL: `${window.location.origin}/orgs/verify`,
|
redirectURL: redirectUrl ?? defaultRedirectUrl,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
setStripeClientSecret(clientSecret);
|
setStripeClientSecret(clientSecret);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -224,20 +251,22 @@ export default function CreateOrgDialog() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={isOpen ?? open} onOpenChange={handleOpenChange}>
|
||||||
<DialogTrigger asChild>
|
{!hideNewOrgButton && (
|
||||||
<Button
|
<DialogTrigger asChild>
|
||||||
disabled={maintenanceActive}
|
<Button
|
||||||
className={cn(
|
disabled={maintenanceActive}
|
||||||
'flex h-8 w-full flex-row justify-start gap-3 px-2',
|
className={cn(
|
||||||
'bg-background text-foreground hover:bg-accent dark:hover:bg-muted',
|
'flex h-8 w-full flex-row justify-start gap-3 px-2',
|
||||||
)}
|
'bg-background text-foreground hover:bg-accent dark:hover:bg-muted',
|
||||||
onClick={() => setStripeClientSecret('')}
|
)}
|
||||||
>
|
onClick={() => setStripeClientSecret('')}
|
||||||
<Plus className="h-4 w-4 font-bold" strokeWidth={3} />
|
>
|
||||||
New Organization
|
<Plus className="h-4 w-4 font-bold" strokeWidth={3} />
|
||||||
</Button>
|
New Organization
|
||||||
</DialogTrigger>
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
)}
|
||||||
<DialogContent
|
<DialogContent
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-foreground sm:max-w-xl',
|
'text-foreground sm:max-w-xl',
|
||||||
@@ -264,7 +293,7 @@ export default function CreateOrgDialog() {
|
|||||||
<CreateOrgForm
|
<CreateOrgForm
|
||||||
plans={data?.plans}
|
plans={data?.plans}
|
||||||
onSubmit={createOrg}
|
onSubmit={createOrg}
|
||||||
onCancel={() => setOpen(false)}
|
onCancel={() => handleOpenChange(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!loading && stripeClientSecret && (
|
{!loading && stripeClientSecret && (
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/v3/alert';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { type PropsWithChildren, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoAlert({ children, title, icon }: PropsWithChildren<Props>) {
|
||||||
|
const alertClassNames = cn('bg-[#ebf3ff] dark:bg-muted', {
|
||||||
|
'flex gap-2 items-center': !!icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
const descClassNames = cn('text-[0.9375rem] leading-[22px]', {
|
||||||
|
'text-[0.875rem] leading-[1rem]': !!icon,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Alert className={alertClassNames}>
|
||||||
|
{icon && <div>{icon}</div>}
|
||||||
|
<div>
|
||||||
|
{title && <AlertTitle>{title}</AlertTitle>}
|
||||||
|
<AlertDescription className={descClassNames}>
|
||||||
|
{children}
|
||||||
|
</AlertDescription>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default InfoAlert;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as InfoAlert } from './InfoAlert';
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
|
import { CheckoutStatus } from '@/utils/__generated__/graphql';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
loading: boolean;
|
||||||
|
status: CheckoutStatus | null;
|
||||||
|
successMessage: string;
|
||||||
|
loadingMessage: string;
|
||||||
|
errorMessage: string;
|
||||||
|
pendingMessage: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinishOrgCreationProcess({
|
||||||
|
loading,
|
||||||
|
status,
|
||||||
|
successMessage,
|
||||||
|
loadingMessage,
|
||||||
|
errorMessage,
|
||||||
|
pendingMessage,
|
||||||
|
}: Props) {
|
||||||
|
let message: string | undefined;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case CheckoutStatus.Completed: {
|
||||||
|
message = successMessage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CheckoutStatus.Expired: {
|
||||||
|
message = errorMessage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case CheckoutStatus.Open: {
|
||||||
|
message = pendingMessage;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
message = loadingMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex flex-auto overflow-x-hidden">
|
||||||
|
<div className="flex h-full w-full flex-col items-center justify-center space-y-2">
|
||||||
|
{(loading || status === CheckoutStatus.Completed) && (
|
||||||
|
<ActivityIndicator circularProgressProps={{ className: 'w-6 h-6' }} />
|
||||||
|
)}
|
||||||
|
<span>{message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(FinishOrgCreationProcess);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as FinishOrgCreationProcess } from './FinishOrgCreationProcess';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { FinishOrgCreationProcess } from '@/features/orgs/components/common/FinishOrgCreationProcess';
|
||||||
|
import { useFinishOrgCreation } from '@/features/orgs/hooks/useFinishOrgCreation';
|
||||||
|
import { type FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onCompleted: FinishOrgCreationOnCompletedCb;
|
||||||
|
onError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinishOrgCreation({ onCompleted, onError }: Props) {
|
||||||
|
const [loading, status] = useFinishOrgCreation({ onCompleted, onError });
|
||||||
|
return (
|
||||||
|
<FinishOrgCreationProcess
|
||||||
|
loading={loading}
|
||||||
|
status={status}
|
||||||
|
loadingMessage="Processing new organization request"
|
||||||
|
successMessage="Organization created successfully."
|
||||||
|
pendingMessage="Organization creation is pending..."
|
||||||
|
errorMessage="Error occurred while creating the organization. Please try again."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FinishOrgCreation;
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
import {
|
||||||
|
mockMatchMediaValue,
|
||||||
|
mockOrganization,
|
||||||
|
mockOrganizations,
|
||||||
|
mockOrganizationsWithNewOrg,
|
||||||
|
newOrg,
|
||||||
|
} from '@/tests/mocks';
|
||||||
|
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||||
|
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||||
|
import { prefetchNewAppQuery } from '@/tests/msw/mocks/graphql/prefetchNewAppQuery';
|
||||||
|
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||||
|
import {
|
||||||
|
createGraphqlMockResolver,
|
||||||
|
fireEvent,
|
||||||
|
mockPointerEvent,
|
||||||
|
queryClient,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@/tests/orgs/testUtils';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { afterAll, beforeAll, vi } from 'vitest';
|
||||||
|
import TransferProjectDialog from './TransferProjectDialog';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockPointerEvent();
|
||||||
|
|
||||||
|
const getUseRouterObject = (session_id?: string) => ({
|
||||||
|
basePath: '',
|
||||||
|
pathname: '/orgs/xyz/projects/test-project',
|
||||||
|
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
|
||||||
|
asPath: '/orgs/xyz/projects/test-project',
|
||||||
|
isLocaleDomain: false,
|
||||||
|
isReady: true,
|
||||||
|
isPreview: false,
|
||||||
|
query: {
|
||||||
|
orgSlug: 'xyz',
|
||||||
|
appSubdomain: 'test-project',
|
||||||
|
session_id,
|
||||||
|
},
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
reload: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
beforePopState: vi.fn(),
|
||||||
|
events: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
isFallback: false,
|
||||||
|
});
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
useRouter: vi.fn(),
|
||||||
|
useOrgs: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/features/orgs/projects/hooks/useOrgs', async () => {
|
||||||
|
const actualUseOrgs = await vi.importActual<any>(
|
||||||
|
'@/features/orgs/projects/hooks/useOrgs',
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...actualUseOrgs,
|
||||||
|
useOrgs: mocks.useOrgs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const postOrganizationRequestResolver = createGraphqlMockResolver(
|
||||||
|
'postOrganizationRequest',
|
||||||
|
'mutation',
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.mock('next/router', () => ({
|
||||||
|
useRouter: mocks.useRouter,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function DialogWrapper({
|
||||||
|
defaultOpen = true,
|
||||||
|
}: {
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}) {
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
return <TransferProjectDialog open={open} setOpen={setOpen} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer(tokenQuery);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||||
|
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||||
|
server.listen();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
mocks.useRouter.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens create org dialog when selecting "create new org" and closes transfer dialog', async () => {
|
||||||
|
mocks.useRouter.mockImplementation(() => getUseRouterObject());
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
server.use(getProjectQuery);
|
||||||
|
server.use(getOrganization);
|
||||||
|
mocks.useOrgs.mockImplementation(() => ({
|
||||||
|
orgs: mockOrganizations,
|
||||||
|
currentOrg: mockOrganization,
|
||||||
|
loading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
server.use(prefetchNewAppQuery);
|
||||||
|
|
||||||
|
render(<DialogWrapper />);
|
||||||
|
const organizationCombobox = await screen.findByRole('combobox', {
|
||||||
|
name: /Organization/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(organizationCombobox).toBeInTheDocument();
|
||||||
|
|
||||||
|
await user.click(organizationCombobox);
|
||||||
|
|
||||||
|
const newOrgOption = await screen.findByRole('option', {
|
||||||
|
name: 'New Organization',
|
||||||
|
});
|
||||||
|
await user.click(newOrgOption);
|
||||||
|
expect(organizationCombobox).toHaveTextContent('New Organization');
|
||||||
|
|
||||||
|
const submitButton = await screen.findByText('Continue');
|
||||||
|
expect(submitButton).toHaveTextContent('Continue');
|
||||||
|
|
||||||
|
fireEvent(
|
||||||
|
submitButton,
|
||||||
|
new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButton).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrgTitle = await screen.findByText('New Organization');
|
||||||
|
expect(newOrgTitle).toBeInTheDocument();
|
||||||
|
const closeButton = await screen.findByText('Close');
|
||||||
|
fireEvent(
|
||||||
|
closeButton,
|
||||||
|
new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(newOrgTitle).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitButtonAfterClosingNewOrgDialog =
|
||||||
|
await screen.findByText('Continue');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(submitButtonAfterClosingNewOrgDialog).toHaveTextContent('Continue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`transfer dialog opens automatically when there is a session_id and selects the ${newOrg.name} from the dropdown`, async () => {
|
||||||
|
mocks.useRouter.mockImplementation(() => getUseRouterObject('session_id'));
|
||||||
|
server.use(getProjectQuery);
|
||||||
|
server.use(getOrganization);
|
||||||
|
mocks.useOrgs.mockImplementation(() => ({
|
||||||
|
orgs: mockOrganizations,
|
||||||
|
currentOrg: mockOrganization,
|
||||||
|
loading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
server.use(prefetchNewAppQuery);
|
||||||
|
server.use(postOrganizationRequestResolver.handler);
|
||||||
|
|
||||||
|
render(<DialogWrapper defaultOpen={false} />);
|
||||||
|
const processingNewOrgText = await screen.findByText(
|
||||||
|
'Processing new organization request',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processingNewOrgText).toBeInTheDocument();
|
||||||
|
|
||||||
|
const closeButton = await screen.findByText('Close');
|
||||||
|
|
||||||
|
fireEvent(
|
||||||
|
closeButton,
|
||||||
|
new MouseEvent('click', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {});
|
||||||
|
expect(closeButton).toBeInTheDocument();
|
||||||
|
|
||||||
|
postOrganizationRequestResolver.resolve({
|
||||||
|
billingPostOrganizationRequest: {
|
||||||
|
Status: 'COMPLETED',
|
||||||
|
Slug: newOrg.slug,
|
||||||
|
ClientSecret: null,
|
||||||
|
__typename: 'PostOrganizationRequestResponse',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
mocks.useOrgs.mockImplementation(() => ({
|
||||||
|
orgs: mockOrganizationsWithNewOrg,
|
||||||
|
currentOrg: mockOrganization,
|
||||||
|
loading: false,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const organizationCombobox = await screen.findByRole('combobox', {
|
||||||
|
name: /Organization/i,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(organizationCombobox).toHaveTextContent(newOrg.name);
|
||||||
|
|
||||||
|
const submitButton = await screen.findByText('Transfer');
|
||||||
|
expect(submitButton).not.toBeDisabled();
|
||||||
|
});
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import { Badge } from '@/components/ui/v3/badge';
|
|
||||||
import { Button } from '@/components/ui/v3/button';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,6 +8,8 @@ import {
|
|||||||
|
|
||||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||||
|
import { Badge } from '@/components/ui/v3/badge';
|
||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -25,20 +25,26 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/v3/select';
|
} from '@/components/ui/v3/select';
|
||||||
|
import FinishOrgCreation from '@/features/orgs/components/common/TransferProjectDialog/FinishOrgCreation';
|
||||||
|
import CreateOrgDialog from '@/features/orgs/components/CreateOrgFormDialog/CreateOrgFormDialog';
|
||||||
|
import type { FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||||
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, isNotEmptyValue } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
Organization_Members_Role_Enum,
|
Organization_Members_Role_Enum,
|
||||||
useBillingTransferAppMutation,
|
useBillingTransferAppMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useUserId } from '@nhost/nextjs';
|
import { useUserId } from '@nhost/nextjs';
|
||||||
|
import { Plus } from 'lucide-react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const CREATE_NEW_ORG = 'createNewOrg';
|
||||||
interface TransferProjectDialogProps {
|
interface TransferProjectDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
setOpen: (value: boolean) => void;
|
setOpen: (value: boolean) => void;
|
||||||
@@ -52,11 +58,21 @@ export default function TransferProjectDialog({
|
|||||||
open,
|
open,
|
||||||
setOpen,
|
setOpen,
|
||||||
}: TransferProjectDialogProps) {
|
}: TransferProjectDialogProps) {
|
||||||
const { push } = useRouter();
|
const { push, asPath, query, replace, pathname } = useRouter();
|
||||||
|
const { session_id, test, ...remainingQuery } = query;
|
||||||
const currentUserId = useUserId();
|
const currentUserId = useUserId();
|
||||||
const { project, loading: projectLoading } = useProject();
|
const { project, loading: projectLoading } = useProject();
|
||||||
const { orgs, currentOrg, loading: orgsLoading } = useOrgs();
|
const {
|
||||||
|
orgs,
|
||||||
|
currentOrg,
|
||||||
|
loading: orgsLoading,
|
||||||
|
refetch: refetchOrgs,
|
||||||
|
} = useOrgs();
|
||||||
const [transferProject] = useBillingTransferAppMutation();
|
const [transferProject] = useBillingTransferAppMutation();
|
||||||
|
const [showCreateOrgModal, setShowCreateOrgModal] = useState(false);
|
||||||
|
const [finishOrgCreation, setFinishOrgCreation] = useState(false);
|
||||||
|
const [preventClose, setPreventClose] = useState(false);
|
||||||
|
const [newOrgSlug, setNewOrgSlug] = useState<string | undefined>();
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof transferProjectFormSchema>>({
|
const form = useForm<z.infer<typeof transferProjectFormSchema>>({
|
||||||
resolver: zodResolver(transferProjectFormSchema),
|
resolver: zodResolver(transferProjectFormSchema),
|
||||||
@@ -65,29 +81,62 @@ export default function TransferProjectDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session_id) {
|
||||||
|
setOpen(true);
|
||||||
|
setFinishOrgCreation(true);
|
||||||
|
setPreventClose(true);
|
||||||
|
}
|
||||||
|
}, [session_id, setOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNotEmptyValue(newOrgSlug)) {
|
||||||
|
const newOrg = orgs.find((org) => org.slug === newOrgSlug);
|
||||||
|
if (newOrg) {
|
||||||
|
form.setValue('organization', newOrg?.id, { shouldDirty: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [newOrgSlug, orgs, form]);
|
||||||
|
|
||||||
|
const createNewFormSelected = form.watch('organization') === CREATE_NEW_ORG;
|
||||||
|
const submitButtonText = createNewFormSelected ? 'Continue' : 'Transfer';
|
||||||
|
|
||||||
|
const path = asPath.split('?')[0];
|
||||||
|
const redirectUrl = `${window.location.origin}${path}`;
|
||||||
|
|
||||||
|
const handleCreateDialogOpenStateChange = (newState: boolean) => {
|
||||||
|
setShowCreateOrgModal(newState);
|
||||||
|
setOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = async (
|
const onSubmit = async (
|
||||||
values: z.infer<typeof transferProjectFormSchema>,
|
values: z.infer<typeof transferProjectFormSchema>,
|
||||||
) => {
|
) => {
|
||||||
const { organization } = values;
|
const { organization } = values;
|
||||||
|
|
||||||
await execPromiseWithErrorToast(
|
if (organization === CREATE_NEW_ORG) {
|
||||||
async () => {
|
setShowCreateOrgModal(true);
|
||||||
await transferProject({
|
setOpen(false);
|
||||||
variables: {
|
} else {
|
||||||
appID: project?.id,
|
await execPromiseWithErrorToast(
|
||||||
organizationID: organization,
|
async () => {
|
||||||
},
|
await transferProject({
|
||||||
});
|
variables: {
|
||||||
|
appID: project?.id,
|
||||||
|
organizationID: organization,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const targetOrg = orgs.find((o) => o.id === organization);
|
const targetOrg = orgs.find((o) => o.id === organization);
|
||||||
await push(`/orgs/${targetOrg.slug}/projects`);
|
await push(`/orgs/${targetOrg.slug}/projects`);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
loadingMessage: 'Transferring project...',
|
loadingMessage: 'Transferring project...',
|
||||||
successMessage: 'Project transferred successfully!',
|
successMessage: 'Project transferred successfully!',
|
||||||
errorMessage: 'Error transferring project. Please try again.',
|
errorMessage: 'Error transferring project. Please try again.',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isUserAdminOfOrg = (org: Org, userId: string) =>
|
const isUserAdminOfOrg = (org: Org, userId: string) =>
|
||||||
@@ -97,103 +146,161 @@ export default function TransferProjectDialog({
|
|||||||
member.user.id === userId,
|
member.user.id === userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const removeSessionIdFromQuery = () => {
|
||||||
|
replace({ pathname, query: remainingQuery }, undefined, { shallow: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFinishOrgCreationCompleted: FinishOrgCreationOnCompletedCb =
|
||||||
|
async (data) => {
|
||||||
|
const { Slug } = data;
|
||||||
|
|
||||||
|
await refetchOrgs();
|
||||||
|
setNewOrgSlug(Slug);
|
||||||
|
setFinishOrgCreation(false);
|
||||||
|
removeSessionIdFromQuery();
|
||||||
|
setPreventClose(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTransferProjectDialogOpenChange = (newValue: boolean) => {
|
||||||
|
if (preventClose) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!newValue) {
|
||||||
|
setNewOrgSlug(undefined);
|
||||||
|
}
|
||||||
|
form.reset();
|
||||||
|
setOpen(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
if (projectLoading || orgsLoading) {
|
if (projectLoading || orgsLoading) {
|
||||||
return <LoadingScreen />;
|
return <LoadingScreen />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<>
|
||||||
open={open}
|
<Dialog open={open} onOpenChange={handleTransferProjectDialogOpenChange}>
|
||||||
onOpenChange={(value) => {
|
<DialogContent className="z-[9999] text-foreground sm:max-w-xl">
|
||||||
form.reset();
|
<DialogHeader className="flex gap-2">
|
||||||
setOpen(value);
|
<DialogTitle>
|
||||||
}}
|
Move the current project to a different organization.{' '}
|
||||||
>
|
</DialogTitle>
|
||||||
<DialogContent className="z-[9999] text-foreground sm:max-w-xl">
|
|
||||||
<DialogHeader className="flex gap-2">
|
|
||||||
<DialogTitle>
|
|
||||||
Move the current project to a different organization.
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
To transfer a project between organizations, you must be an{' '}
|
|
||||||
<span className="font-bold">ADMIN</span> in both.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Form {...form}>
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="organization"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>Organization</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Organization" />
|
|
||||||
</SelectTrigger>
|
|
||||||
</FormControl>
|
|
||||||
<SelectContent>
|
|
||||||
{orgs.map((org) => (
|
|
||||||
<SelectItem
|
|
||||||
key={org.id}
|
|
||||||
value={org.id}
|
|
||||||
disabled={
|
|
||||||
org.plan.isFree || // disable the personal org
|
|
||||||
org.id === currentOrg.id || // disable the current org as it can't be a destination org
|
|
||||||
!isUserAdminOfOrg(org, currentUserId) // disable orgs that the current user is not admin of
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{org.name}
|
|
||||||
<Badge
|
|
||||||
variant={org.plan.isFree ? 'outline' : 'default'}
|
|
||||||
className={cn(
|
|
||||||
org.plan.isFree ? 'bg-muted' : '',
|
|
||||||
'hover:none ml-2 h-5 px-[6px] text-[10px]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{org.plan.name}
|
|
||||||
</Badge>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2">
|
{!finishOrgCreation && (
|
||||||
<Button
|
<DialogDescription>
|
||||||
variant="secondary"
|
To transfer a project between organizations, you must be an{' '}
|
||||||
type="button"
|
<span className="font-bold">ADMIN</span> in both.
|
||||||
disabled={form.formState.isSubmitting}
|
<br />
|
||||||
onClick={() => {
|
When transferred to a new organization, the project will adopt
|
||||||
form.reset();
|
that organization’s plan.
|
||||||
setOpen(false);
|
</DialogDescription>
|
||||||
}}
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
{finishOrgCreation ? (
|
||||||
|
<FinishOrgCreation
|
||||||
|
onCompleted={handleFinishOrgCreationCompleted}
|
||||||
|
onError={() => setPreventClose(false)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
>
|
>
|
||||||
Cancel
|
<FormField
|
||||||
</Button>
|
control={form.control}
|
||||||
<Button
|
name="organization"
|
||||||
type="submit"
|
render={({ field }) => (
|
||||||
disabled={
|
<FormItem>
|
||||||
form.formState.isSubmitting || !form.formState.isDirty
|
<FormLabel>Organization</FormLabel>
|
||||||
}
|
<Select
|
||||||
>
|
onValueChange={field.onChange}
|
||||||
{form.formState.isSubmitting ? (
|
value={field.value}
|
||||||
<ActivityIndicator />
|
>
|
||||||
) : (
|
<FormControl>
|
||||||
'Transfer'
|
<SelectTrigger>
|
||||||
)}
|
<SelectValue placeholder="Organization" />
|
||||||
</Button>
|
</SelectTrigger>
|
||||||
</div>
|
</FormControl>
|
||||||
</form>
|
<SelectContent>
|
||||||
</Form>
|
{orgs.map((org) => (
|
||||||
</DialogContent>
|
<SelectItem
|
||||||
</Dialog>
|
key={org.id}
|
||||||
|
value={org.id}
|
||||||
|
disabled={
|
||||||
|
org.plan.isFree || // disable the personal org
|
||||||
|
org.id === currentOrg.id || // disable the current org as it can't be a destination org
|
||||||
|
!isUserAdminOfOrg(org, currentUserId) // disable orgs that the current user is not admin of
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{org.name}
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
org.plan.isFree ? 'outline' : 'default'
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
org.plan.isFree ? 'bg-muted' : '',
|
||||||
|
'hover:none ml-2 h-5 px-[6px] text-[10px]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{org.plan.name}
|
||||||
|
</Badge>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem
|
||||||
|
key={CREATE_NEW_ORG}
|
||||||
|
value={CREATE_NEW_ORG}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<Plus
|
||||||
|
className="h-4 w-4 font-bold"
|
||||||
|
strokeWidth={3}
|
||||||
|
/>{' '}
|
||||||
|
<span>New Organization</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
type="button"
|
||||||
|
disabled={form.formState.isSubmitting || preventClose}
|
||||||
|
onClick={() => {
|
||||||
|
form.reset();
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
form.formState.isSubmitting || !form.formState.isDirty
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting ? (
|
||||||
|
<ActivityIndicator />
|
||||||
|
) : (
|
||||||
|
submitButtonText
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<CreateOrgDialog
|
||||||
|
hideNewOrgButton
|
||||||
|
isOpen={showCreateOrgModal}
|
||||||
|
onOpenStateChange={handleCreateDialogOpenStateChange}
|
||||||
|
redirectUrl={redirectUrl}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import { mockMatchMediaValue } from '@/tests/mocks';
|
||||||
|
import { getOrganizations } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||||
|
|
||||||
|
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||||
|
import { mockSession } from '@/tests/orgs/mocks';
|
||||||
|
import { queryClient, render, waitFor } from '@/tests/orgs/testUtils';
|
||||||
|
import { CheckoutStatus } from '@/utils/__generated__/graphql';
|
||||||
|
|
||||||
|
import { setupServer } from 'msw/node';
|
||||||
|
import { afterAll, beforeAll, vi } from 'vitest';
|
||||||
|
import NotificationsTray from './NotificationsTray';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUseRouterObject = (session_id?: string) => ({
|
||||||
|
basePath: '',
|
||||||
|
pathname: '/orgs/xyz/projects/test-project',
|
||||||
|
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
|
||||||
|
asPath: '/orgs/xyz/projects/test-project',
|
||||||
|
isLocaleDomain: false,
|
||||||
|
isReady: true,
|
||||||
|
isPreview: false,
|
||||||
|
query: {
|
||||||
|
orgSlug: 'xyz',
|
||||||
|
appSubdomain: 'test-project',
|
||||||
|
session_id,
|
||||||
|
},
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
reload: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
beforePopState: vi.fn(),
|
||||||
|
events: {
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
},
|
||||||
|
isFallback: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
useRouter: vi.fn(),
|
||||||
|
useOrganizationNewRequestsLazyQuery: vi.fn(),
|
||||||
|
usePostOrganizationRequestMutation: vi.fn(),
|
||||||
|
useOrganizationMemberInvitesLazyQuery: vi.fn(),
|
||||||
|
fetchPostOrganizationResponseMock: vi.fn(),
|
||||||
|
userData: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/router', () => ({
|
||||||
|
useRouter: mocks.useRouter,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@nhost/nextjs', async () => {
|
||||||
|
const actualNhostNextjs = await vi.importActual<any>('@nhost/nextjs');
|
||||||
|
return {
|
||||||
|
...actualNhostNextjs,
|
||||||
|
userData: mocks.userData,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/utils/__generated__/graphql', async () => {
|
||||||
|
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useOrganizationNewRequestsLazyQuery:
|
||||||
|
mocks.useOrganizationNewRequestsLazyQuery,
|
||||||
|
usePostOrganizationRequestMutation:
|
||||||
|
mocks.usePostOrganizationRequestMutation,
|
||||||
|
useOrganizationMemberInvitesLazyQuery:
|
||||||
|
mocks.useOrganizationMemberInvitesLazyQuery,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = setupServer(tokenQuery);
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||||
|
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
queryClient.clear();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchOrganizationMemberInvitesMock = () => [
|
||||||
|
async () => ({ data: { organizationMemberInvites: [] } }),
|
||||||
|
{
|
||||||
|
loading: true,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
data: { organizationMemberInvites: [] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchOrganizationNewRequestsResponseMock = async () => ({
|
||||||
|
data: {
|
||||||
|
organizationNewRequests: [
|
||||||
|
{
|
||||||
|
id: 'org-request-id-1',
|
||||||
|
sessionID: 'session-id-1',
|
||||||
|
__typename: 'organization_new_request',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchPostOrganizationResponseMock = vi.fn();
|
||||||
|
|
||||||
|
test('if there is NO session_id in the url the billingPostOrganizationRequest is fetched from the server', async () => {
|
||||||
|
server.use(getOrganizations);
|
||||||
|
mocks.useOrganizationMemberInvitesLazyQuery.mockImplementation(
|
||||||
|
fetchOrganizationMemberInvitesMock,
|
||||||
|
);
|
||||||
|
mocks.useRouter.mockImplementation(() => getUseRouterObject());
|
||||||
|
mocks.userData.mockImplementation(() => mockSession.user);
|
||||||
|
mocks.useOrganizationNewRequestsLazyQuery.mockImplementation(() => [
|
||||||
|
fetchOrganizationNewRequestsResponseMock,
|
||||||
|
]);
|
||||||
|
mocks.usePostOrganizationRequestMutation.mockImplementation(() => [
|
||||||
|
fetchPostOrganizationResponseMock.mockImplementation(() => ({
|
||||||
|
data: {
|
||||||
|
billingPostOrganizationRequest: {
|
||||||
|
Status: CheckoutStatus.Open,
|
||||||
|
Slug: 'newOrgSlug',
|
||||||
|
ClientSecret: 'very_secret_secret',
|
||||||
|
__typename: 'PostOrganizationRequestResponse',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<NotificationsTray />);
|
||||||
|
await waitFor(() => {
|
||||||
|
/* Wait for the component to be update */
|
||||||
|
});
|
||||||
|
expect(fetchPostOrganizationResponseMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('if there is a session_id in the url the billingPostOrganizationRequest is NOT fetched from the server ', async () => {
|
||||||
|
server.use(getOrganizations);
|
||||||
|
mocks.useOrganizationMemberInvitesLazyQuery.mockImplementation(
|
||||||
|
fetchOrganizationMemberInvitesMock,
|
||||||
|
);
|
||||||
|
mocks.useRouter.mockImplementation(() => getUseRouterObject('SESSION_ID'));
|
||||||
|
mocks.userData.mockImplementation(() => mockSession.user);
|
||||||
|
mocks.useOrganizationNewRequestsLazyQuery.mockImplementation(() => [
|
||||||
|
fetchOrganizationNewRequestsResponseMock,
|
||||||
|
]);
|
||||||
|
mocks.usePostOrganizationRequestMutation.mockImplementation(() => [
|
||||||
|
fetchPostOrganizationResponseMock,
|
||||||
|
]);
|
||||||
|
|
||||||
|
render(<NotificationsTray />);
|
||||||
|
await waitFor(() => {
|
||||||
|
/* Wait for the component to be update */
|
||||||
|
});
|
||||||
|
expect(fetchPostOrganizationResponseMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedForm';
|
import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedForm';
|
||||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
|
import { isEmptyValue } from '@/lib/utils';
|
||||||
import {
|
import {
|
||||||
CheckoutStatus,
|
CheckoutStatus,
|
||||||
useDeleteOrganizationMemberInviteMutation,
|
useDeleteOrganizationMemberInviteMutation,
|
||||||
@@ -38,7 +39,8 @@ type Invite = OrganizationMemberInvitesQuery['organizationMemberInvites'][0];
|
|||||||
|
|
||||||
export default function NotificationsTray() {
|
export default function NotificationsTray() {
|
||||||
const userData = useUserData();
|
const userData = useUserData();
|
||||||
const { asPath, route, push } = useRouter();
|
const { asPath, route, push, query } = useRouter();
|
||||||
|
const { session_id } = query;
|
||||||
const { refetch: refetchOrgs } = useOrgs();
|
const { refetch: refetchOrgs } = useOrgs();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
@@ -76,7 +78,6 @@ export default function NotificationsTray() {
|
|||||||
userID: userData.id,
|
userID: userData.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (organizationNewRequests.length > 0) {
|
if (organizationNewRequests.length > 0) {
|
||||||
const { sessionID } = organizationNewRequests.at(0);
|
const { sessionID } = organizationNewRequests.at(0);
|
||||||
|
|
||||||
@@ -109,10 +110,20 @@ export default function NotificationsTray() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userData && !['/', '/orgs/verify'].includes(route)) {
|
if (
|
||||||
|
userData &&
|
||||||
|
!['/', '/orgs/verify'].includes(route) &&
|
||||||
|
isEmptyValue(session_id)
|
||||||
|
) {
|
||||||
checkForPendingOrgRequests();
|
checkForPendingOrgRequests();
|
||||||
}
|
}
|
||||||
}, [route, userData, getOrganizationNewRequests, postOrganizationRequest]);
|
}, [
|
||||||
|
route,
|
||||||
|
userData,
|
||||||
|
getOrganizationNewRequests,
|
||||||
|
postOrganizationRequest,
|
||||||
|
session_id,
|
||||||
|
]);
|
||||||
|
|
||||||
const [acceptInvite] = useOrganizationMemberInviteAcceptMutation();
|
const [acceptInvite] = useOrganizationMemberInviteAcceptMutation();
|
||||||
const [deleteInvite] = useDeleteOrganizationMemberInviteMutation();
|
const [deleteInvite] = useDeleteOrganizationMemberInviteMutation();
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useDatabasePiTRSettings } from './useDatabasePiTRSettings';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useIsPiTREnabled } from '@/features/orgs/hooks/useIsPiTREnabled';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function useDatabasePiTRSettings() {
|
||||||
|
const [isPiTREnabled, setIsPiTREnabled] = useState(false);
|
||||||
|
const [isNotSwitchTouched, setIsNotSwitchTouched] = useState(true);
|
||||||
|
|
||||||
|
const { isPiTREnabled: isPiTREnabledData } = useIsPiTREnabled();
|
||||||
|
useEffect(() => {
|
||||||
|
setIsPiTREnabled(isPiTREnabledData);
|
||||||
|
}, [isPiTREnabledData]);
|
||||||
|
|
||||||
|
const isSwitchDisabled =
|
||||||
|
isPiTREnabled === isPiTREnabledData || isNotSwitchTouched;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isPiTREnabled,
|
||||||
|
setIsPiTREnabled,
|
||||||
|
isSwitchDisabled,
|
||||||
|
setIsNotSwitchTouched,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useDatabasePiTRSettings;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useFinishOrgCreation } from './useFinishOrgCreation';
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import {
|
||||||
|
CheckoutStatus,
|
||||||
|
type PostOrganizationRequestMutation,
|
||||||
|
usePostOrganizationRequestMutation,
|
||||||
|
} from '@/utils/__generated__/graphql';
|
||||||
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
|
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type FinishOrgCreationOnCompletedCb = (
|
||||||
|
data: PostOrganizationRequestMutation['billingPostOrganizationRequest'],
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
interface UseFinishOrgCreationProps {
|
||||||
|
onCompleted: FinishOrgCreationOnCompletedCb;
|
||||||
|
onError?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFinishOrgCreation({
|
||||||
|
onCompleted,
|
||||||
|
onError,
|
||||||
|
}: UseFinishOrgCreationProps): [boolean, CheckoutStatus] {
|
||||||
|
const router = useRouter();
|
||||||
|
const { session_id } = router.query;
|
||||||
|
|
||||||
|
const { isAuthenticated } = useAuthenticationStatus();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [postOrganizationRequest] = usePostOrganizationRequestMutation();
|
||||||
|
const [status, setPostOrganizationRequestStatus] =
|
||||||
|
useState<CheckoutStatus | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function finishOrgCreation() {
|
||||||
|
if (session_id && isAuthenticated) {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
execPromiseWithErrorToast(
|
||||||
|
async () => {
|
||||||
|
const {
|
||||||
|
data: { billingPostOrganizationRequest },
|
||||||
|
} = await postOrganizationRequest({
|
||||||
|
variables: {
|
||||||
|
sessionID: session_id as string,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { Status } = billingPostOrganizationRequest;
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setPostOrganizationRequestStatus(Status);
|
||||||
|
|
||||||
|
switch (Status) {
|
||||||
|
case CheckoutStatus.Completed:
|
||||||
|
onCompleted(billingPostOrganizationRequest);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case CheckoutStatus.Expired:
|
||||||
|
onError();
|
||||||
|
throw new Error('Request to create organization has expired');
|
||||||
|
|
||||||
|
case CheckoutStatus.Open:
|
||||||
|
// TODO discuss what to do in this case
|
||||||
|
onError();
|
||||||
|
throw new Error(
|
||||||
|
'Request to create organization with status "Open"',
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingMessage: 'Processing new organization request',
|
||||||
|
successMessage:
|
||||||
|
'The new organization has been created successfully.',
|
||||||
|
errorMessage:
|
||||||
|
'An error occurred while creating the new organization.',
|
||||||
|
onError,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finishOrgCreation();
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [session_id, isAuthenticated]);
|
||||||
|
|
||||||
|
return [loading, status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useFinishOrgCreation;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useImportBackupSourceProjectList } from './useImportBackupSourceProjectList';
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { useGetProjectsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
function useImportBackupSourceProjectList() {
|
||||||
|
const { org } = useCurrentOrg();
|
||||||
|
const { project } = useProject();
|
||||||
|
|
||||||
|
const currentProjectRegionId = project?.region.id;
|
||||||
|
const projectId = project?.id;
|
||||||
|
const { data, loading } = useGetProjectsQuery({
|
||||||
|
variables: {
|
||||||
|
orgSlug: org?.slug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const filteredProjects = useMemo(
|
||||||
|
() =>
|
||||||
|
(data?.apps || [])
|
||||||
|
.filter(
|
||||||
|
(app) =>
|
||||||
|
app.id !== projectId && app.region.id === currentProjectRegionId,
|
||||||
|
)
|
||||||
|
.map((app) => ({
|
||||||
|
label: `${app.name} (${app.region.name})`,
|
||||||
|
id: app.id,
|
||||||
|
})),
|
||||||
|
[data?.apps, currentProjectRegionId, projectId],
|
||||||
|
);
|
||||||
|
return { filteredProjects, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useImportBackupSourceProjectList;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useIsPiTREnabled } from './useIsPiTREnabled';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { isNotEmptyValue as isNotNull } from '@/lib/utils';
|
||||||
|
import { useGetPostgresSettingsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
function useIsPiTREnabled() {
|
||||||
|
const { project } = useProject();
|
||||||
|
const { data, loading } = useGetPostgresSettingsQuery({
|
||||||
|
variables: { appId: project?.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPiTREnabled = useMemo(
|
||||||
|
() => isNotNull(data?.config.postgres.pitr?.retention),
|
||||||
|
[data?.config.postgres.pitr?.retention],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isPiTREnabled, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useIsPiTREnabled;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useIsPiTREnabledLazy } from './useIsPiTREnabledLazy';
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { isNotEmptyValue } from '@/lib/utils';
|
||||||
|
import { useGetPostgresSettingsLazyQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
|
function useIsPiTREnabledLazy(appId?: string) {
|
||||||
|
const [getPostgresSettings, { data, loading }] =
|
||||||
|
useGetPostgresSettingsLazyQuery({
|
||||||
|
fetchPolicy: 'no-cache',
|
||||||
|
});
|
||||||
|
const prevAppId = useRef<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchPiTRSettings() {
|
||||||
|
if (isNotEmptyValue(appId) && prevAppId.current !== appId) {
|
||||||
|
await getPostgresSettings({ variables: { appId } });
|
||||||
|
prevAppId.current = appId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchPiTRSettings();
|
||||||
|
}, [appId, getPostgresSettings]);
|
||||||
|
|
||||||
|
const isPiTREnabled = useMemo(
|
||||||
|
() => isNotEmptyValue(data?.config.postgres.pitr?.retention),
|
||||||
|
[data?.config.postgres.pitr?.retention],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { isPiTREnabled, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useIsPiTREnabledLazy;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { isNotEmptyValue } from '@/lib/utils';
|
||||||
|
import { useGetPiTrBaseBackupsLazyQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import { triggerToast } from '@/utils/toast';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
function usePiTRBaseBackups(appId: string) {
|
||||||
|
const [earliestBackupDate, setEarliestBackup] = useState<string>();
|
||||||
|
const [fetchPiTRBaseBackups, { loading }] = useGetPiTrBaseBackupsLazyQuery();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function getPiTRBaseBackups() {
|
||||||
|
if (appId) {
|
||||||
|
const { data, error } = await fetchPiTRBaseBackups({
|
||||||
|
variables: { appId },
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
triggerToast(
|
||||||
|
'An error occurred while fetching the Point-in-Time backup data. Please try again later.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isNotEmptyValue(data.getPiTRBaseBackups)) {
|
||||||
|
const earliestBackup = data.getPiTRBaseBackups.slice(-1).pop();
|
||||||
|
setEarliestBackup(earliestBackup.date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
getPiTRBaseBackups();
|
||||||
|
}, [appId, fetchPiTRBaseBackups]);
|
||||||
|
|
||||||
|
return { earliestBackupDate, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default usePiTRBaseBackups;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useRestoreApplicationDatabasePiTR } from './useRestoreApplicationDatabasePiTR';
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useRestoreApplicationDatabasePiTrMutation } from '@/utils/__generated__/graphql';
|
||||||
|
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||||
|
|
||||||
|
function useRestoreApplicationDatabasePiTR() {
|
||||||
|
const [restoreApplicationDatabaseMutation, { loading }] =
|
||||||
|
useRestoreApplicationDatabasePiTrMutation();
|
||||||
|
|
||||||
|
async function restoreApplicationDatabase(
|
||||||
|
variables: { appId: string; recoveryTarget: string; fromAppId?: string },
|
||||||
|
onCompleted?: () => void,
|
||||||
|
) {
|
||||||
|
await execPromiseWithErrorToast(
|
||||||
|
async () => {
|
||||||
|
await restoreApplicationDatabaseMutation({
|
||||||
|
variables,
|
||||||
|
onCompleted,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingMessage: 'Starting restore from backup...',
|
||||||
|
successMessage: 'Backup has been scheduled successfully.',
|
||||||
|
errorMessage:
|
||||||
|
'An error occurred while attempting to schedule a backup. Please try again.',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
restoreApplicationDatabase,
|
||||||
|
loading,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useRestoreApplicationDatabasePiTR;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as useUpdateDatabasePiTRConfig } from './useUpdateDatabasePiTRConfig';
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
import { RECOVERY_RETENTION_PERIOD_7 } from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants/postgresqlConstants';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
|
import { useUpdateConfigMutation } from '@/utils/__generated__/graphql';
|
||||||
|
|
||||||
|
function useUpdateDatabasePiTRConfig() {
|
||||||
|
const { project } = useProject();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [updateConfig] = useUpdateConfigMutation();
|
||||||
|
|
||||||
|
const updatePiTRConfig = useCallback(
|
||||||
|
async (isPiTREnabled: boolean) => {
|
||||||
|
const pitr = isPiTREnabled
|
||||||
|
? { retention: RECOVERY_RETENTION_PERIOD_7 }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const updateConfigMutationPromise = updateConfig({
|
||||||
|
variables: {
|
||||||
|
appId: project?.id,
|
||||||
|
config: {
|
||||||
|
postgres: {
|
||||||
|
pitr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await execPromiseWithErrorToast(
|
||||||
|
async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await updateConfigMutationPromise;
|
||||||
|
setLoading(false);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
loadingMessage: `${isPiTREnabled ? 'Enabling' : 'Disabling'} Point-in-Time recovery...`,
|
||||||
|
successMessage: `Point-in-Time has been ${isPiTREnabled ? 'enabled' : 'disabled'} successfully.`,
|
||||||
|
errorMessage:
|
||||||
|
'An error occurred while trying to enable Point-in-Time recovery.',
|
||||||
|
onError: () => setLoading(false),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateConfig, project?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { updatePiTRConfig, loading };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useUpdateDatabasePiTRConfig;
|
||||||
@@ -20,13 +20,11 @@ import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatfo
|
|||||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
|
||||||
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import {
|
import {
|
||||||
RemoteAppGetUsersDocument,
|
RemoteAppGetUsersAndAuthRolesDocument,
|
||||||
useGetProjectLocalesQuery,
|
useGetProjectLocalesQuery,
|
||||||
useGetRolesPermissionsQuery,
|
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
} from '@/utils/__generated__/graphql';
|
} from '@/utils/__generated__/graphql';
|
||||||
import { copy } from '@/utils/copy';
|
import { copy } from '@/utils/copy';
|
||||||
@@ -114,13 +112,12 @@ export default function EditUserForm({
|
|||||||
const { onDirtyStateChange, openDialog } = useDialog();
|
const { onDirtyStateChange, openDialog } = useDialog();
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
|
|
||||||
const isAnonymous = user.roles.some((role) => role.role === 'anonymous');
|
|
||||||
const [isUserBanned, setIsUserBanned] = useState(user.disabled);
|
const [isUserBanned, setIsUserBanned] = useState(user.disabled);
|
||||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
|
|
||||||
const [updateUser] = useUpdateRemoteAppUserMutation({
|
const [updateUser] = useUpdateRemoteAppUserMutation({
|
||||||
client: remoteProjectGQLClient,
|
client: remoteProjectGQLClient,
|
||||||
refetchQueries: [RemoteAppGetUsersDocument],
|
refetchQueries: [RemoteAppGetUsersAndAuthRolesDocument],
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<EditUserFormValues>({
|
const form = useForm<EditUserFormValues>({
|
||||||
@@ -198,15 +195,6 @@ export default function EditUserForm({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
|
||||||
variables: { appId: project?.id },
|
|
||||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const allAvailableProjectRoles = getUserRoles(
|
|
||||||
dataRoles?.config?.auth?.user?.roles?.allowed,
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data } = useGetProjectLocalesQuery({
|
const { data } = useGetProjectLocalesQuery({
|
||||||
variables: {
|
variables: {
|
||||||
appId: project?.id,
|
appId: project?.id,
|
||||||
@@ -489,47 +477,46 @@ export default function EditUserForm({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
{!isAnonymous && (
|
<Box component="section" className="grid grid-flow-row gap-y-10 p-6">
|
||||||
<Box
|
<ControlledSelect
|
||||||
component="section"
|
{...register('defaultRole')}
|
||||||
className="grid grid-flow-row gap-y-10 p-6"
|
id="defaultRole"
|
||||||
|
name="defaultRole"
|
||||||
|
variant="inline"
|
||||||
|
label="Default Role"
|
||||||
|
slotProps={{ root: { className: 'truncate' } }}
|
||||||
|
hideEmptyHelperText
|
||||||
|
fullWidth
|
||||||
|
error={!!errors.defaultRole}
|
||||||
|
helperText={errors?.defaultRole?.message}
|
||||||
>
|
>
|
||||||
<ControlledSelect
|
{roles.map((role, i) => (
|
||||||
{...register('defaultRole')}
|
<Option
|
||||||
id="defaultRole"
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
name="defaultRole"
|
key={`defaultRoles.${i}`}
|
||||||
variant="inline"
|
value={Object.keys(role)[0]}
|
||||||
label="Default Role"
|
>
|
||||||
slotProps={{ root: { className: 'truncate' } }}
|
{Object.keys(role)[0]}
|
||||||
hideEmptyHelperText
|
</Option>
|
||||||
fullWidth
|
))}
|
||||||
error={!!errors.defaultRole}
|
</ControlledSelect>
|
||||||
helperText={errors?.defaultRole?.message}
|
<div className="grid grid-flow-row place-content-start gap-6 lg:grid-flow-col lg:grid-cols-8">
|
||||||
>
|
<InputLabel as="h3" className="col-span-2">
|
||||||
{allAvailableProjectRoles.map((role) => (
|
Allowed Roles
|
||||||
<Option key={role.name} value={role.name}>
|
</InputLabel>
|
||||||
{role.name}
|
<div className="col-span-3 grid grid-flow-row gap-6">
|
||||||
</Option>
|
{roles.map((role, i) => (
|
||||||
|
<ControlledCheckbox
|
||||||
|
id={`roles.${i}`}
|
||||||
|
label={Object.keys(role)[0]}
|
||||||
|
name={`roles.${i}`}
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={`roles.${i}`}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ControlledSelect>
|
|
||||||
<div className="grid grid-flow-row place-content-start gap-6 lg:grid-flow-col lg:grid-cols-8">
|
|
||||||
<InputLabel as="h3" className="col-span-2">
|
|
||||||
Allowed Roles
|
|
||||||
</InputLabel>
|
|
||||||
<div className="col-span-3 grid grid-flow-row gap-6">
|
|
||||||
{roles.map((role, i) => (
|
|
||||||
<ControlledCheckbox
|
|
||||||
id={`roles.${i}`}
|
|
||||||
label={Object.keys(role)[0]}
|
|
||||||
name={`roles.${i}`}
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
key={`roles.${i}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Box>
|
</div>
|
||||||
)}
|
</Box>
|
||||||
<Box component="section" className="grid grid-flow-row gap-8 p-6">
|
<Box component="section" className="grid grid-flow-row gap-8 p-6">
|
||||||
<Input
|
<Input
|
||||||
{...register('metadata', { onChange: handleMetadataChange })}
|
{...register('metadata', { onChange: handleMetadataChange })}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteAp
|
|||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import type { DialogFormProps } from '@/types/common';
|
import type { DialogFormProps } from '@/types/common';
|
||||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||||
import {
|
import {
|
||||||
useGetSignInMethodsQuery,
|
useGetSignInMethodsQuery,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
@@ -26,7 +26,7 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
|||||||
/**
|
/**
|
||||||
* The selected user.
|
* The selected user.
|
||||||
*/
|
*/
|
||||||
user: RemoteAppGetUsersQuery['users'][0];
|
user: RemoteAppGetUsersAndAuthRolesQuery['users'][0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EditUserPasswordForm({
|
export default function EditUserPasswordForm({
|
||||||
|
|||||||
@@ -15,15 +15,11 @@ import { Text } from '@/components/ui/v2/Text';
|
|||||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||||
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
|
||||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
|
||||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
|
||||||
import type { RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
import type { RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||||
|
import type { Role } from '@/types/application';
|
||||||
import {
|
import {
|
||||||
useDeleteRemoteAppUserRolesMutation,
|
useDeleteRemoteAppUserRolesMutation,
|
||||||
useGetRolesPermissionsQuery,
|
|
||||||
useInsertRemoteAppUserRolesMutation,
|
useInsertRemoteAppUserRolesMutation,
|
||||||
useRemoteAppDeleteUserMutation,
|
useRemoteAppDeleteUserMutation,
|
||||||
useUpdateRemoteAppUserMutation,
|
useUpdateRemoteAppUserMutation,
|
||||||
@@ -33,7 +29,7 @@ import { formatDistance } from 'date-fns';
|
|||||||
import kebabCase from 'just-kebab-case';
|
import kebabCase from 'just-kebab-case';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Fragment, useMemo } from 'react';
|
import { Fragment } from 'react';
|
||||||
|
|
||||||
const EditUserForm = dynamic(
|
const EditUserForm = dynamic(
|
||||||
() =>
|
() =>
|
||||||
@@ -59,14 +55,16 @@ export interface UsersBodyProps {
|
|||||||
* @example onSuccessfulAction={() => router.reload()}
|
* @example onSuccessfulAction={() => router.reload()}
|
||||||
*/
|
*/
|
||||||
onSubmit?: () => Promise<any>;
|
onSubmit?: () => Promise<any>;
|
||||||
|
allAvailableProjectRoles: Role[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
export default function UsersBody({
|
||||||
|
users,
|
||||||
|
onSubmit,
|
||||||
|
allAvailableProjectRoles,
|
||||||
|
}: UsersBodyProps) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isPlatform = useIsPlatform();
|
|
||||||
const localMimirClient = useLocalMimirClient();
|
|
||||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||||
const { project } = useProject();
|
|
||||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||||
|
|
||||||
const [deleteUser] = useRemoteAppDeleteUserMutation({
|
const [deleteUser] = useRemoteAppDeleteUserMutation({
|
||||||
@@ -85,23 +83,6 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
|||||||
client: remoteProjectGQLClient,
|
client: remoteProjectGQLClient,
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* We want to fetch the queries of the application on this page since we're
|
|
||||||
* going to use once the user selects a user of their application; we use it
|
|
||||||
* in the drawer form.
|
|
||||||
*/
|
|
||||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
|
||||||
variables: { appId: project?.id },
|
|
||||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
|
||||||
|
|
||||||
const allAvailableProjectRoles = useMemo(
|
|
||||||
() => getUserRoles(allowedRoles),
|
|
||||||
[allowedRoles],
|
|
||||||
);
|
|
||||||
|
|
||||||
async function handleEditUser(
|
async function handleEditUser(
|
||||||
values: EditUserFormValues,
|
values: EditUserFormValues,
|
||||||
user: RemoteAppUser,
|
user: RemoteAppUser,
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default as BackupList } from './BackupList';
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './BackupListItem';
|
|
||||||
export { default as BackupListItem } from './BackupListItem';
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/v3/tabs';
|
||||||
|
import { ImportBackupTabContent } from '@/features/orgs/projects/backups/components/ImportBackupTabContent';
|
||||||
|
import { PointInTimeTabsContent } from '@/features/orgs/projects/backups/components/PointInTimeTabsContent';
|
||||||
|
import { ScheduledBackupTabContent } from '@/features/orgs/projects/backups/components/ScheduledBackupTabContent';
|
||||||
|
import { memo, useState } from 'react';
|
||||||
|
|
||||||
|
function BackupsContent({ isPiTREnabled }: { isPiTREnabled: boolean }) {
|
||||||
|
const [tab, setTab] = useState(() =>
|
||||||
|
isPiTREnabled ? 'pointInTime' : 'scheduledBackups',
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="scheduledBackups">Scheduled backups</TabsTrigger>
|
||||||
|
<TabsTrigger value="pointInTime">Point-in-time</TabsTrigger>
|
||||||
|
<TabsTrigger value="importBackup">Import backup</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<div className="pt-7">
|
||||||
|
<ScheduledBackupTabContent />
|
||||||
|
{tab === 'pointInTime' && <PointInTimeTabsContent />}
|
||||||
|
{tab === 'importBackup' && <ImportBackupTabContent />}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BackupsContent);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as BackupsContent } from './BackupsContent';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { TabsContent } from '@/components/ui/v3/tabs';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import SourceProjectBackupInfo from './SourceProjectBackupInfo';
|
||||||
|
import SourceProjectSelect from './SourceProjectSelect';
|
||||||
|
|
||||||
|
function ImportBackupContent() {
|
||||||
|
const { project } = useProject();
|
||||||
|
const [sourceProject, setSourceProject] = useState<{
|
||||||
|
label: string;
|
||||||
|
id: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function handleProjectSelect(selectedProject: { label: string; id: string }) {
|
||||||
|
setSourceProject(selectedProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = sourceProject
|
||||||
|
? `Import backup from ${sourceProject.label}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent value="importBackup">
|
||||||
|
<div className="flex flex-col gap-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="mb-4 text-base leading-5">
|
||||||
|
<strong>Target project:</strong> {project?.name} (
|
||||||
|
{project?.region.name})
|
||||||
|
</h1>
|
||||||
|
<SourceProjectSelect
|
||||||
|
projectId={sourceProject?.id}
|
||||||
|
onProjectSelect={handleProjectSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{sourceProject && (
|
||||||
|
<SourceProjectBackupInfo appId={sourceProject.id} title={title} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ImportBackupContent;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import { CircleAlert } from 'lucide-react';
|
||||||
|
|
||||||
|
function NoOtherProjectsInRegion() {
|
||||||
|
const { project } = useProject();
|
||||||
|
return (
|
||||||
|
<InfoAlert
|
||||||
|
title={`There are no other projects within the region: ${project.region.name}`}
|
||||||
|
icon={<CircleAlert className="h-[38px] w-[38px]" />}
|
||||||
|
>
|
||||||
|
Backups may be imported from projects that are in the same region and
|
||||||
|
organization.
|
||||||
|
</InfoAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NoOtherProjectsInRegion;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||||
|
import { DatabaseZap } from 'lucide-react';
|
||||||
|
|
||||||
|
function PiTRNotEnabledOnSourceProject() {
|
||||||
|
return (
|
||||||
|
<InfoAlert
|
||||||
|
title="Point-in-Time recovery is not enabled on the selected project"
|
||||||
|
icon={<DatabaseZap className="h-[38px] w-[38px]" />}
|
||||||
|
>
|
||||||
|
Importing from scheduled backups is not supported yet. Coming soon!
|
||||||
|
</InfoAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PiTRNotEnabledOnSourceProject;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useIsPiTREnabledLazy } from '@/features/orgs/hooks/useIsPiTREnabledLazy';
|
||||||
|
import { PointInTimeBackupInfo } from '@/features/orgs/projects/backups/components/common/PointInTimeBackupInfo';
|
||||||
|
import PiTRNotEnabledOnSourceProject from './PiTRNotEnabledOnSourceProject';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
appId: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceProjectBackupInfo({ appId, title }: Props) {
|
||||||
|
const { isPiTREnabled } = useIsPiTREnabledLazy(appId);
|
||||||
|
return isPiTREnabled ? (
|
||||||
|
<PointInTimeBackupInfo
|
||||||
|
appId={appId}
|
||||||
|
title={title}
|
||||||
|
dialogTitle="Import backup"
|
||||||
|
dialogButtonText="Import backup"
|
||||||
|
dialogTriggerText="Start import"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<PiTRNotEnabledOnSourceProject />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SourceProjectBackupInfo;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/v3/select';
|
||||||
|
import { useImportBackupSourceProjectList } from '@/features/orgs/hooks/useImportBackupSourceProjectList';
|
||||||
|
import { isEmptyValue } from '@/lib/utils';
|
||||||
|
import NoOtherProjectsInRegion from './NoOtherProjectsInRegion';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onProjectSelect: (project: { label: string; id: string }) => void;
|
||||||
|
projectId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SourceProjectSelect({ onProjectSelect, projectId }: Props) {
|
||||||
|
const { filteredProjects, loading } = useImportBackupSourceProjectList();
|
||||||
|
|
||||||
|
if (!loading && isEmptyValue(filteredProjects)) {
|
||||||
|
return <NoOtherProjectsInRegion />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleChange(value: string) {
|
||||||
|
const selectedProject = filteredProjects.find((fp) => fp.id === value);
|
||||||
|
|
||||||
|
onProjectSelect(selectedProject);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-max">
|
||||||
|
<p className="pb-1 text-[#21324B] dark:text-[#DFECF5]">Source project</p>
|
||||||
|
<Select value={projectId} onValueChange={handleChange} disabled={loading}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a project to import backup from" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{filteredProjects.map((project) => (
|
||||||
|
<SelectItem key={project.id} value={project.id}>
|
||||||
|
{project.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="pt-1 text-[#9CA7B7] dark:text-[#68717A]">
|
||||||
|
Backups can be imported from projects that are in the same organization
|
||||||
|
and region.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SourceProjectSelect;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ImportBackupTabContent } from './ImportBackupTabContent';
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||||
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
function PiTRNotEnabled() {
|
||||||
|
const { org } = useCurrentOrg();
|
||||||
|
const { project } = useProject();
|
||||||
|
return (
|
||||||
|
<InfoAlert>
|
||||||
|
To enable Point-in-Time recovery, enable it in the{' '}
|
||||||
|
<Link
|
||||||
|
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings/database`}
|
||||||
|
className="text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
database settings.
|
||||||
|
</Link>
|
||||||
|
</InfoAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PiTRNotEnabled;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PointInTimeBackupInfo } from '@/features/orgs/projects/backups/components/common/PointInTimeBackupInfo';
|
||||||
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
|
import RecoveryRetentionPeriod from './RecoveryRetentionPeriod';
|
||||||
|
|
||||||
|
function PointInTimeRecovery() {
|
||||||
|
const { project } = useProject();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-[1.875rem]">
|
||||||
|
<RecoveryRetentionPeriod />
|
||||||
|
<PointInTimeBackupInfo appId={project?.id} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PointInTimeRecovery;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Spinner } from '@/components/ui/v3/spinner';
|
||||||
|
import { TabsContent } from '@/components/ui/v3/tabs';
|
||||||
|
import { useIsPiTREnabled } from '@/features/orgs/hooks/useIsPiTREnabled';
|
||||||
|
import PiTRNotEnabled from './PiTRNotEnabled';
|
||||||
|
import PointInTimeRecovery from './PointInTimeRecovery';
|
||||||
|
|
||||||
|
function PointInTimeTabsContent() {
|
||||||
|
const { isPiTREnabled, loading } = useIsPiTREnabled();
|
||||||
|
const content = isPiTREnabled ? <PointInTimeRecovery /> : <PiTRNotEnabled />;
|
||||||
|
return (
|
||||||
|
<TabsContent value="pointInTime">
|
||||||
|
{loading ? <Spinner /> : content}
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PointInTimeTabsContent;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||||
|
import { CalendarClock } from 'lucide-react';
|
||||||
|
|
||||||
|
function RecoveryRetentionPeriod() {
|
||||||
|
return (
|
||||||
|
<InfoAlert
|
||||||
|
title="Recovery retention period"
|
||||||
|
icon={<CalendarClock className="h-[38px] w-[38px]" />}
|
||||||
|
>
|
||||||
|
Database changes are retained for up to 7 days, allowing restoration to
|
||||||
|
any point within this period.
|
||||||
|
</InfoAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RecoveryRetentionPeriod;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as PointInTimeTabsContent } from './PointInTimeTabsContent';
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './RestoreBackupModal';
|
|
||||||
export { default as RestoreBackupModal } from './RestoreBackupModal';
|
|
||||||
@@ -6,9 +6,9 @@ import { TableContainer } from '@/components/ui/v2/TableContainer';
|
|||||||
import { TableHead } from '@/components/ui/v2/TableHead';
|
import { TableHead } from '@/components/ui/v2/TableHead';
|
||||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||||
import { Text } from '@/components/ui/v2/Text';
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
import { BackupListItem } from '@/features/orgs/projects/backups/components/BackupListItem';
|
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import { useGetApplicationBackupsQuery } from '@/utils/__generated__/graphql';
|
import { useGetApplicationBackupsQuery } from '@/utils/__generated__/graphql';
|
||||||
|
import BackupListItem from './BackupListItem';
|
||||||
|
|
||||||
export default function BackupList() {
|
export default function BackupList() {
|
||||||
const { project, loading: loadingProject } = useProject();
|
const { project, loading: loadingProject } = useProject();
|
||||||
@@ -2,13 +2,13 @@ import { useDialog } from '@/components/common/DialogProvider';
|
|||||||
import { Button } from '@/components/ui/v2/Button';
|
import { Button } from '@/components/ui/v2/Button';
|
||||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||||
import { RestoreBackupModal } from '@/features/orgs/projects/backups/components/RestoreBackupModal';
|
|
||||||
import type { Backup } from '@/types/application';
|
import type { Backup } from '@/types/application';
|
||||||
import { useGetBackupPresignedUrlLazyQuery } from '@/utils/__generated__/graphql';
|
import { useGetBackupPresignedUrlLazyQuery } from '@/utils/__generated__/graphql';
|
||||||
import { prettifySize } from '@/utils/prettifySize';
|
import { prettifySize } from '@/utils/prettifySize';
|
||||||
import { triggerToast } from '@/utils/toast';
|
import { triggerToast } from '@/utils/toast';
|
||||||
import { format, formatDistanceStrict, parseISO } from 'date-fns';
|
import { format, formatDistanceStrict, parseISO } from 'date-fns';
|
||||||
import { twMerge } from 'tailwind-merge';
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
import RestoreBackupModal from './RestoreBackupModal';
|
||||||
|
|
||||||
export interface BackupListItemProps {
|
export interface BackupListItemProps {
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||||
|
|
||||||
|
function PiTREnabledInfoBanner() {
|
||||||
|
return (
|
||||||
|
<InfoAlert>
|
||||||
|
With PiTR enabled, Scheduled backups are no longer taken. PiTR provides
|
||||||
|
more precise recovery, making additional backups unnecessary.
|
||||||
|
</InfoAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PiTREnabledInfoBanner;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Text } from '@/components/ui/v2/Text';
|
||||||
|
import { Spinner } from '@/components/ui/v3/spinner';
|
||||||
|
import { TabsContent } from '@/components/ui/v3/tabs';
|
||||||
|
import { useIsPiTREnabled } from '@/features/orgs/hooks/useIsPiTREnabled';
|
||||||
|
import BackupList from './BackupList';
|
||||||
|
import PiTREnabledInfoBanner from './PiTREnabledInfoBanner';
|
||||||
|
|
||||||
|
function ScheduledBackupTabContent() {
|
||||||
|
const { isPiTREnabled, loading } = useIsPiTREnabled();
|
||||||
|
const content = isPiTREnabled ? (
|
||||||
|
<PiTREnabledInfoBanner />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Text variant="h3" className="pb-2">
|
||||||
|
Database
|
||||||
|
</Text>
|
||||||
|
<Text color="secondary">
|
||||||
|
The database backup includes database schema, database data and Hasura
|
||||||
|
metadata. It does not include the actual files in Storage.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BackupList />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<TabsContent value="scheduledBackups">
|
||||||
|
<div className="grid w-full grid-flow-row gap-6">
|
||||||
|
{loading ? <Spinner /> : content}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduledBackupTabContent;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { default as ScheduledBackupTabContent } from './ScheduledBackupTabContent';
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import { DialogFooter } from '@/components/ui/v3/dialog';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
import { memo } from 'react';
|
||||||
|
|
||||||
|
function LogsLink({ href, children }: PropsWithChildren<{ href: string }>) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onClose: () => void;
|
||||||
|
orgSlug: string;
|
||||||
|
subdomain: string;
|
||||||
|
}
|
||||||
|
//
|
||||||
|
function BackupScheduledInfo({ onClose, orgSlug, subdomain }: Props) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>Your backup has been scheduled successfully and will start shortly.</p>
|
||||||
|
<p>
|
||||||
|
To follow its process go to the{' '}
|
||||||
|
<LogsLink href={`/orgs/${orgSlug}/projects/${subdomain}/logs`}>
|
||||||
|
Logs page
|
||||||
|
</LogsLink>{' '}
|
||||||
|
and select the service "Backup Job" to see the restore logs.
|
||||||
|
</p>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" onClick={onClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(BackupScheduledInfo);
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { TimezonePicker } from '@/components/common/TimezonePicker';
|
||||||
|
import { Button } from '@/components/ui/v3/button';
|
||||||
|
import { Spinner } from '@/components/ui/v3/spinner';
|
||||||
|
import { getDateTimeStringWithUTCOffset } from '@/features/orgs/projects/backups/utils/getDateTimeStringWithUTCOffset';
|
||||||
|
import { isEmptyValue } from '@/lib/utils';
|
||||||
|
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dateTime: string;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EarliestBackupDateTime({ dateTime }: Pick<Props, 'dateTime'>) {
|
||||||
|
const [selectedTimezone, setTimezone] = useState<string>(() =>
|
||||||
|
guessTimezone(),
|
||||||
|
);
|
||||||
|
function handleSelect(tz: { value: string; label: string }) {
|
||||||
|
setTimezone(tz.value);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p className="flex items-center gap-2 text-[1.125rem]">
|
||||||
|
<span data-testid="EarliestBackupDateTime">
|
||||||
|
{getDateTimeStringWithUTCOffset(dateTime, selectedTimezone)}
|
||||||
|
</span>
|
||||||
|
<TimezonePicker
|
||||||
|
dateTime={dateTime}
|
||||||
|
selectedTimezone={selectedTimezone}
|
||||||
|
onTimezoneSelect={handleSelect}
|
||||||
|
button={
|
||||||
|
<Button className="h-auto p-0" variant="link">
|
||||||
|
Change timezone
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EarliestBackup({ dateTime, loading }: Props) {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-[27px] max-w-fit">
|
||||||
|
<Spinner size="small" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const hasNoPiTRBackups = !loading && isEmptyValue(dateTime);
|
||||||
|
if (hasNoPiTRBackups) {
|
||||||
|
return <p className="text-[1.125rem]">Project has no backups yet.</p>;
|
||||||
|
}
|
||||||
|
return <EarliestBackupDateTime dateTime={dateTime} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EarliestBackup;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user