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
|
||||
with:
|
||||
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
|
||||
name: Install packages
|
||||
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
|
||||
description: 'Action to install the Nhost CLI and to run an application'
|
||||
inputs:
|
||||
init:
|
||||
description: 'Initialize the application'
|
||||
default: 'false'
|
||||
start:
|
||||
description: "Start the application. If false, the application won't be started"
|
||||
default: 'false'
|
||||
@@ -16,6 +19,9 @@ inputs:
|
||||
version:
|
||||
description: 'Version of the Nhost CLI'
|
||||
default: 'latest'
|
||||
dashboard-image:
|
||||
description: 'Image of the dashboard'
|
||||
default: 'nhost/dashboard:latest'
|
||||
config:
|
||||
description: 'Values to be injected into nhost/config.yaml'
|
||||
|
||||
@@ -40,6 +46,13 @@ runs:
|
||||
timeout_minutes: 3
|
||||
max_attempts: 10
|
||||
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
|
||||
if: ${{ inputs.config }}
|
||||
shell: bash
|
||||
@@ -50,7 +63,12 @@ runs:
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.path }}
|
||||
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
|
||||
- name: Log on failure
|
||||
if: steps.wait.outcome == 'failure'
|
||||
|
||||
30
.github/workflows/ci.yaml
vendored
30
.github/workflows/ci.yaml
vendored
@@ -134,10 +134,27 @@ jobs:
|
||||
with:
|
||||
TURBO_TOKEN: ${{ env.TURBO_TOKEN }}
|
||||
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
|
||||
- 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
|
||||
# * 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
|
||||
id: fetch-dashboard-preview-url
|
||||
uses: zentered/vercel-preview-url@v1.1.9
|
||||
@@ -157,6 +174,17 @@ jobs:
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 20
|
||||
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
|
||||
if: ${{ failure() }}
|
||||
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
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
path: packages/nhost-js
|
||||
init: true
|
||||
start: true
|
||||
- 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:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
- name: Install the Nhost CLI, start and stop the application
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
path: packages/nhost-js
|
||||
init: true
|
||||
start: true
|
||||
stop: true
|
||||
- name: should have no live docker container
|
||||
@@ -55,12 +55,13 @@ jobs:
|
||||
- name: Install the Nhost CLI and run the application
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
path: packages/nhost-js
|
||||
init: true
|
||||
version: v1.29.3
|
||||
start: true
|
||||
- name: should find the injected hasura-auth version
|
||||
run: |
|
||||
VERSION=$(curl -sSf 'https://local.auth.nhost.run/v1/version')
|
||||
EXPECTED_VERSION='{"version":"v0.20.1"}'
|
||||
VERSION=$(curl -sSf 'https://local.auth.local.nhost.run/v1/version')
|
||||
EXPECTED_VERSION='{"version":"0.36.1"}'
|
||||
if [ "$VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "Expected version $EXPECTED_VERSION but got $VERSION"
|
||||
exit 1
|
||||
@@ -73,6 +74,6 @@ jobs:
|
||||
- name: Install the Nhost CLI
|
||||
uses: ./.github/actions/nhost-cli
|
||||
with:
|
||||
version: v1.0.1
|
||||
version: v1.27.2
|
||||
- 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
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
/.vscode/
|
||||
|
||||
12
README.md
12
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
# Nhost
|
||||
|
||||
<a href="https://docs.nhost.io/#quickstart">Quickstart</a>
|
||||
<a href="https://docs.nhost.io/introduction#quick-start-guides">Quickstart</a>
|
||||
<span> • </span>
|
||||
<a href="http://nhost.io/">Website</a>
|
||||
<span> • </span>
|
||||
@@ -36,7 +36,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- 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
|
||||
|
||||
@@ -89,12 +89,12 @@ await nhost.graphql.request(`{
|
||||
Nhost is frontend agnostic, which means Nhost works with all frontend frameworks.
|
||||
|
||||
<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/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/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>
|
||||
|
||||
# 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:
|
||||
|
||||
- **[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.
|
||||
- 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
|
||||
|
||||
# Environment Variables for Self Hosting and Local Development
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.local.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||
|
||||
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
|
||||
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
||||
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||
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_ZENDESK_URL=
|
||||
@@ -22,6 +23,6 @@ NEXT_PUBLIC_ZENDESK_API_KEY=
|
||||
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
|
||||
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",
|
||||
"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
|
||||
COPY .gitignore .gitignore
|
||||
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
|
||||
|
||||
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
|
||||
NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||
```
|
||||
|
||||
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('');
|
||||
}
|
||||
|
||||
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:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
- https://local.graphql.local.nhost.run/v1:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
generates:
|
||||
|
||||
@@ -7,7 +7,7 @@ const { version } = require('./package.json');
|
||||
const cspHeader = `
|
||||
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
||||
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com;
|
||||
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';
|
||||
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
||||
font-src 'self' data:;
|
||||
@@ -16,6 +16,8 @@ const cspHeader = `
|
||||
form-action 'self';
|
||||
frame-ancestors 'none';
|
||||
frame-src 'self' js.stripe.com;
|
||||
block-all-mixed-content;
|
||||
upgrade-insecure-requests;
|
||||
`;
|
||||
|
||||
module.exports = withBundleAnalyzer({
|
||||
@@ -36,9 +38,13 @@ module.exports = withBundleAnalyzer({
|
||||
{
|
||||
source: '/(.*)',
|
||||
headers: [
|
||||
{
|
||||
key: 'Content-Security-Policy',
|
||||
value: cspHeader.replace(/\s+/g, ' ').trim(),
|
||||
},
|
||||
{
|
||||
key: 'X-Frame-Options',
|
||||
value: 'SAMEORIGIN',
|
||||
value: 'DENY',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.18.0",
|
||||
"version": "2.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -16,13 +16,15 @@
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e": "pnpm install-browsers && pnpm playwright test"
|
||||
"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": {
|
||||
"@apollo/client": "^3.9.9",
|
||||
"@codemirror/lang-sql": "^6.6.2",
|
||||
"@codemirror/language": "^6.10.1",
|
||||
"@codemirror/legacy-modes": "^6.4.0",
|
||||
"@date-fns/tz": "^1.2.0",
|
||||
"@emotion/cache": "^11.11.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@emotion/server": "^11.11.0",
|
||||
@@ -55,24 +57,25 @@
|
||||
"@radix-ui/react-select": "^2.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@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/stripe-js": "^1.54.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tanstack/react-query": "^4.36.1",
|
||||
"@tanstack/react-table": "^8.15.3",
|
||||
"@tanstack/react-virtual": "^3.2.0",
|
||||
"@tanstack/react-virtual": "^3.5.0",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
"cmdk": "1.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"date-fns-v4": "npm:date-fns@4.1.0",
|
||||
"dequal": "^2.0.3",
|
||||
"framer-motion": "^10.18.0",
|
||||
"generate-password": "^1.7.1",
|
||||
@@ -93,6 +96,7 @@
|
||||
"react": "18.2.0",
|
||||
"react-children-utilities": "^2.10.0",
|
||||
"react-complex-tree": "^2.4.5",
|
||||
"react-day-picker": "8.10.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "^7.53.0",
|
||||
@@ -113,6 +117,7 @@
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"timezones-list": "^3.1.0",
|
||||
"utility-types": "^3.11.0",
|
||||
"uuid": "^9.0.1",
|
||||
"validator": "^13.11.0",
|
||||
|
||||
@@ -15,7 +15,6 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
@@ -28,6 +27,11 @@ export default defineConfig({
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: ['**/setup/*.setup.ts'],
|
||||
teardown: 'teardown',
|
||||
},
|
||||
{
|
||||
name: 'teardown',
|
||||
testMatch: ['**/teardown/*.teardown.ts'],
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
@@ -36,6 +40,7 @@ export default defineConfig({
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
},
|
||||
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 { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
@@ -21,11 +20,11 @@ export default function UpgradeToProBanner({
|
||||
title,
|
||||
description,
|
||||
}: UpgradeToProBannerProps) {
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleTransferDialogOpen = () => setTransferProjectDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
@@ -51,29 +50,7 @@ export default function UpgradeToProBanner({
|
||||
</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">
|
||||
<Button
|
||||
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>
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
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,
|
||||
} from '@/components/ui/v3/popover';
|
||||
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -40,6 +41,8 @@ export default function OrgPagesComboBox() {
|
||||
asPath,
|
||||
} = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
const orgPageFromUrl = pathSegments[3] || null;
|
||||
|
||||
@@ -64,7 +67,7 @@ export default function OrgPagesComboBox() {
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTrigger disabled={!isPlatform} asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||
@@ -40,88 +41,10 @@ type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const projectPages = [
|
||||
{
|
||||
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',
|
||||
},
|
||||
];
|
||||
type SelectedOption = Omit<Option, 'disabled'>;
|
||||
|
||||
export default function ProjectPagesComboBox() {
|
||||
const {
|
||||
@@ -130,6 +53,105 @@ export default function ProjectPagesComboBox() {
|
||||
asPath,
|
||||
} = 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 projectPageFromUrl = appSubdomain
|
||||
? pathSegments[5] || 'overview'
|
||||
@@ -137,9 +159,8 @@ export default function ProjectPagesComboBox() {
|
||||
const selectedProjectPageFromUrl = projectPages.find(
|
||||
(item) => item.value === projectPageFromUrl,
|
||||
);
|
||||
const [selectedProjectPage, setSelectedProjectPage] = useState<Option | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProjectPage, setSelectedProjectPage] =
|
||||
useState<SelectedOption | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectPageFromUrl) {
|
||||
@@ -155,6 +176,7 @@ export default function ProjectPagesComboBox() {
|
||||
label: app.label,
|
||||
value: app.slug,
|
||||
icon: app.icon,
|
||||
disabled: app.disabled,
|
||||
}));
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -188,6 +210,7 @@ export default function ProjectPagesComboBox() {
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disabled}
|
||||
onSelect={() => {
|
||||
setSelectedProjectPage(option);
|
||||
setOpen(false);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
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',
|
||||
@@ -53,4 +54,16 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
);
|
||||
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;
|
||||
|
||||
interface DialogContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
|
||||
disableOutsideClick?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, disableOutsideClick, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay>
|
||||
<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',
|
||||
className,
|
||||
)}
|
||||
onInteractOutside={
|
||||
disableOutsideClick
|
||||
? (e) => e.preventDefault()
|
||||
: props.onInteractOutside
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{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 type { DialogFormProps } from '@/types/common';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
RemoteAppGetUsersAndAuthRolesDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -116,7 +116,7 @@ export default function EditUserForm({
|
||||
|
||||
const [updateUser] = useUpdateRemoteAppUserMutation({
|
||||
client: remoteProjectGQLClient,
|
||||
refetchQueries: [RemoteAppGetUsersDocument],
|
||||
refetchQueries: [RemoteAppGetUsersAndAuthRolesDocument],
|
||||
});
|
||||
|
||||
const form = useForm<EditUserFormValues>({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -26,7 +26,7 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
/**
|
||||
* The selected user.
|
||||
*/
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
user: RemoteAppGetUsersAndAuthRolesQuery['users'][0];
|
||||
}
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
|
||||
@@ -15,6 +15,9 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
}
|
||||
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 user = useUserData();
|
||||
const isPlatform = useIsPlatform();
|
||||
@@ -186,6 +202,16 @@ export default function CreateOrgDialog() {
|
||||
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
|
||||
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
||||
|
||||
const handleOpenChange = (newOpenState: boolean) => {
|
||||
const controlledFromOutSide =
|
||||
isPropSet(isOpen) && isPropSet(onOpenStateChange);
|
||||
if (controlledFromOutSide) {
|
||||
onOpenStateChange(newOpenState);
|
||||
} else {
|
||||
setOpen(newOpenState);
|
||||
}
|
||||
};
|
||||
|
||||
const createOrg = async ({
|
||||
name,
|
||||
plan,
|
||||
@@ -195,16 +221,17 @@ export default function CreateOrgDialog() {
|
||||
}) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const defaultRedirectUrl = `${window.location.origin}/orgs/verify`;
|
||||
|
||||
const {
|
||||
data: { billingCreateOrganizationRequest: clientSecret },
|
||||
} = await createOrganizationRequest({
|
||||
variables: {
|
||||
organizationName: name,
|
||||
planID: plan,
|
||||
redirectURL: `${window.location.origin}/orgs/verify`,
|
||||
redirectURL: redirectUrl ?? defaultRedirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setStripeClientSecret(clientSecret);
|
||||
},
|
||||
{
|
||||
@@ -224,20 +251,22 @@ export default function CreateOrgDialog() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={maintenanceActive}
|
||||
className={cn(
|
||||
'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('')}
|
||||
>
|
||||
<Plus className="h-4 w-4 font-bold" strokeWidth={3} />
|
||||
New Organization
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Dialog open={isOpen ?? open} onOpenChange={handleOpenChange}>
|
||||
{!hideNewOrgButton && (
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={maintenanceActive}
|
||||
className={cn(
|
||||
'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('')}
|
||||
>
|
||||
<Plus className="h-4 w-4 font-bold" strokeWidth={3} />
|
||||
New Organization
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'text-foreground sm:max-w-xl',
|
||||
@@ -264,7 +293,7 @@ export default function CreateOrgDialog() {
|
||||
<CreateOrgForm
|
||||
plans={data?.plans}
|
||||
onSubmit={createOrg}
|
||||
onCancel={() => setOpen(false)}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
{!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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +8,8 @@ import {
|
||||
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,20 +25,26 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} 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 { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isNotEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
Organization_Members_Role_Enum,
|
||||
useBillingTransferAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CREATE_NEW_ORG = 'createNewOrg';
|
||||
interface TransferProjectDialogProps {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
@@ -52,11 +58,21 @@ export default function TransferProjectDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: TransferProjectDialogProps) {
|
||||
const { push } = useRouter();
|
||||
const { push, asPath, query, replace, pathname } = useRouter();
|
||||
const { session_id, test, ...remainingQuery } = query;
|
||||
const currentUserId = useUserId();
|
||||
const { project, loading: projectLoading } = useProject();
|
||||
const { orgs, currentOrg, loading: orgsLoading } = useOrgs();
|
||||
const {
|
||||
orgs,
|
||||
currentOrg,
|
||||
loading: orgsLoading,
|
||||
refetch: refetchOrgs,
|
||||
} = useOrgs();
|
||||
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>>({
|
||||
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 (
|
||||
values: z.infer<typeof transferProjectFormSchema>,
|
||||
) => {
|
||||
const { organization } = values;
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await transferProject({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
organizationID: organization,
|
||||
},
|
||||
});
|
||||
if (organization === CREATE_NEW_ORG) {
|
||||
setShowCreateOrgModal(true);
|
||||
setOpen(false);
|
||||
} else {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await transferProject({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
organizationID: organization,
|
||||
},
|
||||
});
|
||||
|
||||
const targetOrg = orgs.find((o) => o.id === organization);
|
||||
await push(`/orgs/${targetOrg.slug}/projects`);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Transferring project...',
|
||||
successMessage: 'Project transferred successfully!',
|
||||
errorMessage: 'Error transferring project. Please try again.',
|
||||
},
|
||||
);
|
||||
const targetOrg = orgs.find((o) => o.id === organization);
|
||||
await push(`/orgs/${targetOrg.slug}/projects`);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Transferring project...',
|
||||
successMessage: 'Project transferred successfully!',
|
||||
errorMessage: 'Error transferring project. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUserAdminOfOrg = (org: Org, userId: string) =>
|
||||
@@ -97,103 +146,161 @@ export default function TransferProjectDialog({
|
||||
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) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
form.reset();
|
||||
setOpen(value);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleTransferProjectDialogOpenChange}>
|
||||
<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>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
{!finishOrgCreation && (
|
||||
<DialogDescription>
|
||||
To transfer a project between organizations, you must be an{' '}
|
||||
<span className="font-bold">ADMIN</span> in both.
|
||||
<br />
|
||||
When transferred to a new organization, the project will adopt
|
||||
that organization’s plan.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{finishOrgCreation ? (
|
||||
<FinishOrgCreation
|
||||
onCompleted={handleFinishOrgCreationCompleted}
|
||||
onError={() => setPreventClose(false)}
|
||||
/>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
form.formState.isSubmitting || !form.formState.isDirty
|
||||
}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
'Transfer'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={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>
|
||||
))}
|
||||
<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 { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
CheckoutStatus,
|
||||
useDeleteOrganizationMemberInviteMutation,
|
||||
@@ -38,7 +39,8 @@ type Invite = OrganizationMemberInvitesQuery['organizationMemberInvites'][0];
|
||||
|
||||
export default function NotificationsTray() {
|
||||
const userData = useUserData();
|
||||
const { asPath, route, push } = useRouter();
|
||||
const { asPath, route, push, query } = useRouter();
|
||||
const { session_id } = query;
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -76,7 +78,6 @@ export default function NotificationsTray() {
|
||||
userID: userData.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (organizationNewRequests.length > 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();
|
||||
}
|
||||
}, [route, userData, getOrganizationNewRequests, postOrganizationRequest]);
|
||||
}, [
|
||||
route,
|
||||
userData,
|
||||
getOrganizationNewRequests,
|
||||
postOrganizationRequest,
|
||||
session_id,
|
||||
]);
|
||||
|
||||
const [acceptInvite] = useOrganizationMemberInviteAcceptMutation();
|
||||
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 { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
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 { DialogFormProps } from '@/types/common';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
RemoteAppGetUsersAndAuthRolesDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
@@ -114,13 +112,12 @@ export default function EditUserForm({
|
||||
const { onDirtyStateChange, openDialog } = useDialog();
|
||||
const { project } = useProject();
|
||||
|
||||
const isAnonymous = user.roles.some((role) => role.role === 'anonymous');
|
||||
const [isUserBanned, setIsUserBanned] = useState(user.disabled);
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
|
||||
const [updateUser] = useUpdateRemoteAppUserMutation({
|
||||
client: remoteProjectGQLClient,
|
||||
refetchQueries: [RemoteAppGetUsersDocument],
|
||||
refetchQueries: [RemoteAppGetUsersAndAuthRolesDocument],
|
||||
});
|
||||
|
||||
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({
|
||||
variables: {
|
||||
appId: project?.id,
|
||||
@@ -489,47 +477,46 @@ export default function EditUserForm({
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
{!isAnonymous && (
|
||||
<Box
|
||||
component="section"
|
||||
className="grid grid-flow-row gap-y-10 p-6"
|
||||
<Box component="section" className="grid grid-flow-row gap-y-10 p-6">
|
||||
<ControlledSelect
|
||||
{...register('defaultRole')}
|
||||
id="defaultRole"
|
||||
name="defaultRole"
|
||||
variant="inline"
|
||||
label="Default Role"
|
||||
slotProps={{ root: { className: 'truncate' } }}
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
error={!!errors.defaultRole}
|
||||
helperText={errors?.defaultRole?.message}
|
||||
>
|
||||
<ControlledSelect
|
||||
{...register('defaultRole')}
|
||||
id="defaultRole"
|
||||
name="defaultRole"
|
||||
variant="inline"
|
||||
label="Default Role"
|
||||
slotProps={{ root: { className: 'truncate' } }}
|
||||
hideEmptyHelperText
|
||||
fullWidth
|
||||
error={!!errors.defaultRole}
|
||||
helperText={errors?.defaultRole?.message}
|
||||
>
|
||||
{allAvailableProjectRoles.map((role) => (
|
||||
<Option key={role.name} value={role.name}>
|
||||
{role.name}
|
||||
</Option>
|
||||
{roles.map((role, i) => (
|
||||
<Option
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={`defaultRoles.${i}`}
|
||||
value={Object.keys(role)[0]}
|
||||
>
|
||||
{Object.keys(role)[0]}
|
||||
</Option>
|
||||
))}
|
||||
</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}`}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
<Box component="section" className="grid grid-flow-row gap-8 p-6">
|
||||
<Input
|
||||
{...register('metadata', { onChange: handleMetadataChange })}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteAp
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -26,7 +26,7 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
/**
|
||||
* The selected user.
|
||||
*/
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
user: RemoteAppGetUsersAndAuthRolesQuery['users'][0];
|
||||
}
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
|
||||
@@ -15,15 +15,11 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||
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 { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
import type { RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||
import type { Role } from '@/types/application';
|
||||
import {
|
||||
useDeleteRemoteAppUserRolesMutation,
|
||||
useGetRolesPermissionsQuery,
|
||||
useInsertRemoteAppUserRolesMutation,
|
||||
useRemoteAppDeleteUserMutation,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -33,7 +29,7 @@ import { formatDistance } from 'date-fns';
|
||||
import kebabCase from 'just-kebab-case';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Image from 'next/image';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const EditUserForm = dynamic(
|
||||
() =>
|
||||
@@ -59,14 +55,16 @@ export interface UsersBodyProps {
|
||||
* @example onSuccessfulAction={() => router.reload()}
|
||||
*/
|
||||
onSubmit?: () => Promise<any>;
|
||||
allAvailableProjectRoles: Role[];
|
||||
}
|
||||
|
||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
export default function UsersBody({
|
||||
users,
|
||||
onSubmit,
|
||||
allAvailableProjectRoles,
|
||||
}: UsersBodyProps) {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||
const { project } = useProject();
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
|
||||
const [deleteUser] = useRemoteAppDeleteUserMutation({
|
||||
@@ -85,23 +83,6 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
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(
|
||||
values: EditUserFormValues,
|
||||
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 { TableRow } from '@/components/ui/v2/TableRow';
|
||||
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 { useGetApplicationBackupsQuery } from '@/utils/__generated__/graphql';
|
||||
import BackupListItem from './BackupListItem';
|
||||
|
||||
export default function BackupList() {
|
||||
const { project, loading: loadingProject } = useProject();
|
||||
@@ -2,13 +2,13 @@ import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { TableCell } from '@/components/ui/v2/TableCell';
|
||||
import { TableRow } from '@/components/ui/v2/TableRow';
|
||||
import { RestoreBackupModal } from '@/features/orgs/projects/backups/components/RestoreBackupModal';
|
||||
import type { Backup } from '@/types/application';
|
||||
import { useGetBackupPresignedUrlLazyQuery } from '@/utils/__generated__/graphql';
|
||||
import { prettifySize } from '@/utils/prettifySize';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { format, formatDistanceStrict, parseISO } from 'date-fns';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import RestoreBackupModal from './RestoreBackupModal';
|
||||
|
||||
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