Compare commits
22 Commits
@nhost/das
...
@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 |
@@ -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
|
||||
|
||||
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
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.20.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",
|
||||
|
||||
@@ -40,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';
|
||||
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';
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -15,6 +15,9 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
}
|
||||
enablePublicAccess
|
||||
}
|
||||
pitr {
|
||||
retention
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
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 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 {
|
||||
RemoteAppGetUsersAndAuthRolesDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { copy } from '@/utils/copy';
|
||||
@@ -114,7 +112,6 @@ 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();
|
||||
|
||||
@@ -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 })}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,240 @@
|
||||
import {
|
||||
fetchEmptyPiTRBaseBackups,
|
||||
fetchPiTRBaseBackups,
|
||||
mockApplication,
|
||||
mockMatchMediaValue,
|
||||
} from '@/tests/mocks';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { render, screen, waitFor } from '@/tests/orgs/testUtils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||
|
||||
import PointInTimeBackupInfo from './PointInTimeBackupInfo';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useGetPiTrBaseBackupsLazyQuery: vi.fn(),
|
||||
fetchPiTRBaseBackups: vi.fn(),
|
||||
restoreApplicationDatabase: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/__generated__/graphql', async () => {
|
||||
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
|
||||
return {
|
||||
...actual,
|
||||
useGetPiTrBaseBackupsLazyQuery: mocks.useGetPiTrBaseBackupsLazyQuery,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/timezoneUtils', async () => {
|
||||
const actualTimezoneUtils = await vi.importActual<any>(
|
||||
'@/utils/timezoneUtils',
|
||||
);
|
||||
return {
|
||||
...actualTimezoneUtils,
|
||||
guessTimezone: () => 'Europe/Helsinki',
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/features/orgs/hooks/useRestoreApplicationDatabasePiTR', () => ({
|
||||
useRestoreApplicationDatabasePiTR: () => ({
|
||||
restoreApplicationDatabase: mocks.restoreApplicationDatabase,
|
||||
loading: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/orgs/projects/hooks/useProject', async () => ({
|
||||
useProject: () => ({ project: mockApplication }),
|
||||
}));
|
||||
|
||||
describe('PointInTimeBackupInfo', () => {
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('will fetch the earliest backup and will display the date in with timezone', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectQuery);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
||||
// '10 March 2025, 05:00:05 (UTC+02:00)'
|
||||
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
|
||||
expect(earliestBackup).toHaveTextContent(
|
||||
'10 Mar 2025, 05:00:05 (UTC+02:00)',
|
||||
);
|
||||
});
|
||||
|
||||
test('will update the date after the timezone is changed', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectQuery);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
||||
const user = userEvent.setup();
|
||||
// '10 March 2025, 05:00:05 (UTC+02:00)'
|
||||
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
|
||||
expect(earliestBackup).toHaveTextContent(
|
||||
'10 Mar 2025, 05:00:05 (UTC+02:00)',
|
||||
);
|
||||
|
||||
const changeTimezoneButton = await screen.getByRole('button', {
|
||||
name: 'Change timezone',
|
||||
});
|
||||
await user.click(changeTimezoneButton);
|
||||
const tzInput = await screen.getByPlaceholderText('Search timezones...');
|
||||
expect(tzInput).toBeInTheDocument();
|
||||
await user.type(tzInput, 'Asia/Amman{ArrowDown}{Enter}');
|
||||
await waitFor(() => expect(tzInput).not.toBeInTheDocument());
|
||||
const updatedEarliestBackup = await screen.getByTestId(
|
||||
'EarliestBackupDateTime',
|
||||
);
|
||||
expect(updatedEarliestBackup).toHaveTextContent(
|
||||
'10 Mar 2025, 06:00:05 (UTC+03:00)',
|
||||
);
|
||||
});
|
||||
|
||||
test('will fetch the earliest backup and display "Project has no backups yet." test if there are now backups and start restore is disabled', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectQuery);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchEmptyPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
||||
// '10 March 2025, 05:00:05 (UTC+02:00)'
|
||||
const earliestBackup = await screen.getByText(
|
||||
'Project has no backups yet.',
|
||||
);
|
||||
expect(earliestBackup).toBeInTheDocument();
|
||||
const startRestoreButton = await screen.getByRole('button', {
|
||||
name: 'Start restore',
|
||||
});
|
||||
expect(startRestoreButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('will schedule a restore', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectQuery);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() =>
|
||||
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const startRestoreButton = await screen.getByRole('button', {
|
||||
name: 'Start restore',
|
||||
});
|
||||
|
||||
await user.click(startRestoreButton);
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByText('Recover your database from a backup'),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
const dateTimePickerButton = await screen.getByRole('button', {
|
||||
name: /UTC/i,
|
||||
});
|
||||
await user.click(dateTimePickerButton);
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Select' }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
await user.click(
|
||||
await screen.getByRole('gridcell', {
|
||||
name: /13/i,
|
||||
}),
|
||||
);
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '18');
|
||||
|
||||
const updatedDateTimeButton = await screen.getByRole('button', {
|
||||
name: /UTC/i,
|
||||
});
|
||||
expect(updatedDateTimeButton).toHaveTextContent(
|
||||
'13 Mar 2025, 18:00:05 (UTC+02:00)',
|
||||
);
|
||||
await user.click(await screen.getByRole('button', { name: 'Select' }));
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.queryByRole('button', { name: 'Select' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(updatedDateTimeButton).toHaveTextContent(
|
||||
'13 Mar 2025, 18:00:05 (UTC+02:00)',
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Restore backup' }),
|
||||
).toBeDisabled();
|
||||
// check checkboxes
|
||||
|
||||
await user.click(
|
||||
await screen.getByLabelText(/I understand that restoring this backup/),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.getByLabelText(/I understand that restoring this backup/),
|
||||
).toBeChecked();
|
||||
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Restore backup' }),
|
||||
).toBeDisabled();
|
||||
|
||||
await user.click(
|
||||
await screen.getByLabelText(/I understand this cannot be undone/),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.getByLabelText(/I understand this cannot be undone/),
|
||||
).toBeChecked();
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Restore backup' }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.getByRole('button', { name: 'Restore backup' }),
|
||||
);
|
||||
|
||||
expect(
|
||||
mocks.restoreApplicationDatabase.mock.calls[0][0].fromAppId,
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
|
||||
).toBe('2025-03-13T16:00:05.000Z');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import usePiTRBaseBackups from '@/features/orgs/hooks/usePiTRBaseBackups/usePiTRBaseBackups';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { Info } from 'lucide-react';
|
||||
import EarliestBackup from './EarliestBackup';
|
||||
import RestoreBackupDialogButton from './RestoreBackupDialogButton';
|
||||
|
||||
interface Props {
|
||||
appId: string;
|
||||
title?: string;
|
||||
dialogTitle?: string;
|
||||
dialogButtonText?: string;
|
||||
dialogTriggerText?: string;
|
||||
}
|
||||
|
||||
function PointInTimeBackupInfo({
|
||||
appId,
|
||||
title,
|
||||
dialogTitle = 'Recover your database from a backup',
|
||||
dialogButtonText,
|
||||
dialogTriggerText,
|
||||
}: Props) {
|
||||
const { earliestBackupDate, loading } = usePiTRBaseBackups(appId);
|
||||
|
||||
const disableStartRestoreButton = loading || isEmptyValue(earliestBackupDate);
|
||||
return (
|
||||
/* Move this part to a different component */
|
||||
<div className="rounded-lg border border-[#EAEDF0] dark:border-[#2F363D]">
|
||||
<div className="flex w-full flex-col items-start gap-6 p-4">
|
||||
<h3 className="leading-[1.375] text-[0.9375]">
|
||||
{title || 'Restore your database from a backup'}
|
||||
</h3>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div>
|
||||
<p className="text-[0.75rem]">Backups are available from</p>
|
||||
<EarliestBackup dateTime={earliestBackupDate} loading={loading} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[0.75rem]">Latest backup</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
<p>
|
||||
Restore available up to current time. System will restore up to
|
||||
closest available target time if exact time unavailable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end border-t border-[#EAEDF0] p-4 dark:border-[#2F363D]">
|
||||
<RestoreBackupDialogButton
|
||||
disabled={disableStartRestoreButton}
|
||||
earliestBackupDate={earliestBackupDate}
|
||||
title={dialogTitle}
|
||||
fromAppId={appId}
|
||||
dialogButtonText={dialogButtonText}
|
||||
dialogTriggerText={dialogTriggerText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PointInTimeBackupInfo;
|
||||
@@ -0,0 +1,210 @@
|
||||
import { DateTimePicker } from '@/components/common/DateTimePicker';
|
||||
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
|
||||
import { useRestoreApplicationDatabasePiTR } from '@/features/orgs/hooks/useRestoreApplicationDatabasePiTR';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import type { TZDate } from '@date-fns/tz';
|
||||
import { DialogDescription } from '@radix-ui/react-dialog';
|
||||
import { format, isBefore, startOfDay } from 'date-fns-v4';
|
||||
import { memo, useCallback, useEffect, useState } from 'react';
|
||||
import BackupScheduledInfo from './BackupScheduledInfo';
|
||||
import StartRestoreConfirmationCheck from './StartRestoreConfirmationCheck';
|
||||
|
||||
interface Props {
|
||||
fromAppId?: string;
|
||||
title: string;
|
||||
earliestBackupDate: string;
|
||||
disabled?: boolean;
|
||||
dialogButtonText?: string;
|
||||
dialogTriggerText?: string;
|
||||
}
|
||||
|
||||
function RestoreBackupDialogButton({
|
||||
title,
|
||||
disabled,
|
||||
earliestBackupDate,
|
||||
fromAppId,
|
||||
dialogButtonText = 'Restore backup',
|
||||
dialogTriggerText = 'Start restore',
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isRestoreScheduled, setIsRestoreScheduled] = useState(false);
|
||||
|
||||
const [restoreTargetTime, setRestoreTargetTime] =
|
||||
useState(earliestBackupDate);
|
||||
|
||||
const [restoreTargetIsBeforeError, setRestoreTargetIsBeforeError] = useState<
|
||||
string | undefined
|
||||
>();
|
||||
|
||||
const [
|
||||
permanentlyDeleteCurrentDataCheck,
|
||||
setPermanentlyDeleteCurrentDataCheck,
|
||||
] = useState(false);
|
||||
const [cannotBeUndoneCheck, setCannotBeUndoneCheck] = useState(false);
|
||||
|
||||
const { project } = useProject();
|
||||
const { org } = useCurrentOrg();
|
||||
|
||||
const { restoreApplicationDatabase, loading } =
|
||||
useRestoreApplicationDatabasePiTR();
|
||||
|
||||
async function handleRestore() {
|
||||
const variables = {
|
||||
appId: project?.id,
|
||||
recoveryTarget: restoreTargetTime,
|
||||
fromAppId: fromAppId === project?.id ? null : fromAppId,
|
||||
};
|
||||
restoreApplicationDatabase(variables, () => setIsRestoreScheduled(true));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (earliestBackupDate) {
|
||||
setRestoreTargetTime(earliestBackupDate);
|
||||
}
|
||||
}, [earliestBackupDate]);
|
||||
|
||||
function formatDateFn(date: Date | TZDate | string) {
|
||||
return format(date, 'dd MMM yyyy, HH:mm:ss (OOOO)').replace('GMT', 'UTC');
|
||||
}
|
||||
|
||||
function isCalendarDayDisabled(date: Date) {
|
||||
return isBefore(startOfDay(date), startOfDay(earliestBackupDate));
|
||||
}
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setRestoreTargetTime(earliestBackupDate);
|
||||
setPermanentlyDeleteCurrentDataCheck(false);
|
||||
setCannotBeUndoneCheck(false);
|
||||
setIsRestoreScheduled(false);
|
||||
setRestoreTargetIsBeforeError(undefined);
|
||||
}, [earliestBackupDate]);
|
||||
|
||||
const handleDateTimeChange = useCallback(
|
||||
(newDateTime: string) => {
|
||||
setRestoreTargetTime(newDateTime);
|
||||
if (isBefore(newDateTime, earliestBackupDate)) {
|
||||
setRestoreTargetIsBeforeError(
|
||||
'Selected date is before the earliest restore target time.',
|
||||
);
|
||||
} else {
|
||||
setRestoreTargetIsBeforeError(undefined);
|
||||
}
|
||||
},
|
||||
[earliestBackupDate],
|
||||
);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newState: boolean) => {
|
||||
if (!newState) {
|
||||
resetState();
|
||||
}
|
||||
setOpen(newState);
|
||||
},
|
||||
[setOpen, resetState],
|
||||
);
|
||||
|
||||
const validateFn = useCallback(
|
||||
(newDateTime: Date) => {
|
||||
if (isBefore(newDateTime, earliestBackupDate)) {
|
||||
return 'Selected date and time is before the earliest available backup';
|
||||
}
|
||||
return '';
|
||||
},
|
||||
[earliestBackupDate],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
handleOpenChange(false);
|
||||
}, [handleOpenChange]);
|
||||
const hasError = !!restoreTargetIsBeforeError;
|
||||
const startRestoreButtonDisabled =
|
||||
!(permanentlyDeleteCurrentDataCheck && cannotBeUndoneCheck) || hasError;
|
||||
|
||||
const permanentlyDeleteCurrentDataCheckLabel = (
|
||||
<span>
|
||||
I understand that restoring this backup will permanently delete all
|
||||
current data for project <b>{project.name}</b>.
|
||||
</span>
|
||||
);
|
||||
|
||||
const dialogTitle = isRestoreScheduled
|
||||
? 'Backup has been scheduled successfully.'
|
||||
: title;
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>
|
||||
<Button disabled={disabled}>{dialogTriggerText}</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent
|
||||
className="text-foreground sm:max-w-xl"
|
||||
disableOutsideClick
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription />
|
||||
</DialogHeader>
|
||||
{isRestoreScheduled && (
|
||||
<BackupScheduledInfo
|
||||
onClose={handleClose}
|
||||
subdomain={project.subdomain}
|
||||
orgSlug={org?.slug}
|
||||
/>
|
||||
)}
|
||||
{!isRestoreScheduled && (
|
||||
<>
|
||||
<div className="flex w-full flex-col gap-9">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p>Restore target time</p>
|
||||
<DateTimePicker
|
||||
dateTime={restoreTargetTime}
|
||||
formatDateFn={formatDateFn}
|
||||
onDateTimeChange={handleDateTimeChange}
|
||||
withTimezone
|
||||
isCalendarDayDisabled={isCalendarDayDisabled}
|
||||
validateDateFn={validateFn}
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<StartRestoreConfirmationCheck
|
||||
checked={permanentlyDeleteCurrentDataCheck}
|
||||
onCheckedChange={setPermanentlyDeleteCurrentDataCheck}
|
||||
id="permanentlyDeleteCurrentDataCheck"
|
||||
label={permanentlyDeleteCurrentDataCheckLabel}
|
||||
/>
|
||||
<StartRestoreConfirmationCheck
|
||||
checked={cannotBeUndoneCheck}
|
||||
onCheckedChange={setCannotBeUndoneCheck}
|
||||
id="cannotBeUndoneCheck"
|
||||
label="I understand this cannot be undone"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={startRestoreButtonDisabled}
|
||||
loading={loading}
|
||||
onClick={handleRestore}
|
||||
>
|
||||
{dialogButtonText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(RestoreBackupDialogButton);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Checkbox } from '@/components/ui/v3/checkbox';
|
||||
import { Separator } from '@/components/ui/v3/separator';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
label: ReactNode;
|
||||
checked: boolean;
|
||||
onCheckedChange: (checked: boolean) => void;
|
||||
id: string;
|
||||
}
|
||||
|
||||
function StartRestoreConfirmationCheck({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: Props) {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 pb-3">
|
||||
<Checkbox id={id} checked={checked} onCheckedChange={onCheckedChange} />
|
||||
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
</div>
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default StartRestoreConfirmationCheck;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as PointInTimeBackupInfo } from './PointInTimeBackupInfo';
|
||||
@@ -0,0 +1,6 @@
|
||||
query GetPiTRBaseBackups($appId: String!) {
|
||||
getPiTRBaseBackups(appID: $appId) {
|
||||
date
|
||||
name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
mutation RestoreApplicationDatabasePiTR(
|
||||
$appId: String!
|
||||
$recoveryTarget: Timestamp!
|
||||
$fromAppId: String
|
||||
) {
|
||||
restoreApplicationDatabasePiTR(
|
||||
appID: $appId
|
||||
recoveryTarget: $recoveryTarget
|
||||
fromAppID: $fromAppId
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import { format } from 'date-fns-v4';
|
||||
|
||||
// This is the only one that is related to the feature
|
||||
export function getDateTimeStringWithUTCOffset(
|
||||
dateTime: string | undefined,
|
||||
timezone: string,
|
||||
) {
|
||||
if (isEmptyValue(dateTime)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new TZDate(dateTime, timezone);
|
||||
|
||||
return format(date, 'dd MMM yyyy, HH:mm:ss (OOOO)').replace('GMT', 'UTC');
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useBillingDeleteAppMutation } from '@/utils/__generated__/graphql';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
@@ -52,6 +53,9 @@ export default function RemoveApplicationModal({
|
||||
const [remove3, setRemove3] = useState(false);
|
||||
|
||||
const appName = project?.name;
|
||||
const isPaidPlan = isEmptyValue(org?.plan?.isFree)
|
||||
? false
|
||||
: !org?.plan?.isFree;
|
||||
|
||||
async function handleClick() {
|
||||
setLoadingRemove(true);
|
||||
@@ -121,21 +125,23 @@ export default function RemoveApplicationModal({
|
||||
aria-label="Confirm Delete Project #2"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
id="accept-3"
|
||||
label="I understand I need to delete the organization if I want to cancel the subscription"
|
||||
className="py-2"
|
||||
checked={remove3}
|
||||
onChange={(_event, checked) => setRemove3(checked)}
|
||||
aria-label="Confirm Delete Project #3"
|
||||
/>
|
||||
{isPaidPlan && (
|
||||
<Checkbox
|
||||
id="accept-3"
|
||||
label="I understand I need to delete the organization if I want to cancel the subscription"
|
||||
className="py-2"
|
||||
checked={remove3}
|
||||
onChange={(_event, checked) => setRemove3(checked)}
|
||||
aria-label="Confirm Delete Project #3"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color="error"
|
||||
onClick={handleClick}
|
||||
disabled={!remove || !remove2 || !remove3}
|
||||
disabled={!remove || !remove2 || (isPaidPlan && !remove3)}
|
||||
loading={loadingRemove}
|
||||
>
|
||||
Delete Project
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { splitPostgresMajorMinorVersions } from '@/features/orgs/projects/database/settings/utils/splitPostgresMajorMinorVersions';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useGetPostgresSettingsQuery } from '@/utils/__generated__/graphql';
|
||||
@@ -22,15 +23,12 @@ export default function useGetPostgresVersion() {
|
||||
});
|
||||
|
||||
const { version } = postgresSettingsData?.config?.postgres || {};
|
||||
const [postgresMajor, postgresMinor] = version?.split('.') || [
|
||||
undefined,
|
||||
undefined,
|
||||
];
|
||||
const { major, minor } = splitPostgresMajorMinorVersions(version);
|
||||
|
||||
return {
|
||||
version,
|
||||
postgresMajor,
|
||||
postgresMinor,
|
||||
major,
|
||||
minor,
|
||||
loading,
|
||||
error,
|
||||
};
|
||||
|
||||
@@ -201,3 +201,5 @@ export const postgresFunctions = {
|
||||
* List of PostgreSQL data types that can be used as identity.
|
||||
*/
|
||||
export const identityTypes: ColumnType[] = ['int2', 'int4', 'int8'];
|
||||
|
||||
export const RECOVERY_RETENTION_PERIOD_7 = 7;
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function DatabaseMigrateWarning() {
|
||||
color: 'error.main',
|
||||
}}
|
||||
>
|
||||
<XIcon className="h-4 w-4" /> Error: Database version upgrade not
|
||||
<XIcon className="h-4 w-4" /> Error: Database major version upgrade not
|
||||
possible
|
||||
</Text>
|
||||
<Text
|
||||
@@ -31,7 +31,7 @@ export default function DatabaseMigrateWarning() {
|
||||
}}
|
||||
>
|
||||
Your project isn't currently in a healthy state. Please, review
|
||||
before proceeding with the upgrade.
|
||||
before proceeding with a major version upgrade.
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import { mockMatchMediaValue } from '@/tests/mocks';
|
||||
import { render, screen } from '@/tests/orgs/testUtils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { vi } from 'vitest';
|
||||
import DatabasePiTRSettings from './DatabasePiTRSettings';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
function getCurrentOrg({ isFree }: { isFree: boolean }) {
|
||||
return {
|
||||
org: {
|
||||
plan: {
|
||||
isFree,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function mockUseGetPostgresSettingsQueryResponse({
|
||||
retention,
|
||||
}: {
|
||||
retention: number | null;
|
||||
}) {
|
||||
const pitr = retention === null ? null : { retention };
|
||||
return {
|
||||
data: {
|
||||
config: {
|
||||
postgres: {
|
||||
pitr,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useCurrentOrg: vi.fn(),
|
||||
useUpdateConfigMutation: vi.fn(),
|
||||
useGetPostgresSettingsQuery: vi.fn(),
|
||||
updateConfigMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/orgs/projects/hooks/useCurrentOrg', async () => {
|
||||
const actualCurrentOrg = await vi.importActual<any>(
|
||||
'@/features/orgs/projects/hooks/useCurrentOrg',
|
||||
);
|
||||
return {
|
||||
...actualCurrentOrg,
|
||||
useCurrentOrg: mocks.useCurrentOrg,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/__generated__/graphql', async () => {
|
||||
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
|
||||
return {
|
||||
...actual,
|
||||
useUpdateConfigMutation: mocks.useUpdateConfigMutation,
|
||||
useGetPostgresSettingsQuery: mocks.useGetPostgresSettingsQuery,
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mocks.useCurrentOrg.mockRestore();
|
||||
mocks.updateConfigMock.mockRestore();
|
||||
mocks.useUpdateConfigMutation.mockRestore();
|
||||
mocks.useGetPostgresSettingsQuery.mockRestore();
|
||||
});
|
||||
|
||||
test('If the org is free the switch should not be available and the save button is disabled', async () => {
|
||||
mocks.useCurrentOrg.mockImplementation(() => getCurrentOrg({ isFree: true }));
|
||||
mocks.useUpdateConfigMutation.mockImplementation(() => [
|
||||
mocks.updateConfigMock,
|
||||
]);
|
||||
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
|
||||
mockUseGetPostgresSettingsQueryResponse({ retention: null }),
|
||||
);
|
||||
render(<DatabasePiTRSettings />);
|
||||
const saveButton = await screen.findByRole('button', {
|
||||
name: 'Save',
|
||||
});
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
expect(await screen.queryByRole('checkbox')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('the Save button is disabled until the switch in the header is not touched', async () => {
|
||||
mocks.useCurrentOrg.mockImplementation(() =>
|
||||
getCurrentOrg({ isFree: false }),
|
||||
);
|
||||
mocks.useUpdateConfigMutation.mockImplementation(() => [
|
||||
mocks.updateConfigMock,
|
||||
]);
|
||||
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
|
||||
mockUseGetPostgresSettingsQueryResponse({ retention: null }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<DatabasePiTRSettings />);
|
||||
const saveButton = await screen.findByRole('button', {
|
||||
name: 'Save',
|
||||
});
|
||||
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
const PiTR = screen.getByRole('checkbox');
|
||||
await user.click(PiTR);
|
||||
expect(PiTR).toBeChecked();
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Save',
|
||||
}),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('should disable the savebutton after toggling back to original state', async () => {
|
||||
mocks.useCurrentOrg.mockImplementation(() =>
|
||||
getCurrentOrg({ isFree: false }),
|
||||
);
|
||||
mocks.useUpdateConfigMutation.mockImplementation(() => [
|
||||
mocks.updateConfigMock,
|
||||
]);
|
||||
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
|
||||
mockUseGetPostgresSettingsQueryResponse({ retention: 7 }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<DatabasePiTRSettings />);
|
||||
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
expect(screen.getByRole('checkbox')).not.toBeChecked();
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: 'Save',
|
||||
}),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should send { retention: 7 } when enabling PiTR', async () => {
|
||||
mocks.useCurrentOrg.mockImplementation(() =>
|
||||
getCurrentOrg({ isFree: false }),
|
||||
);
|
||||
mocks.useUpdateConfigMutation.mockImplementation(() => [
|
||||
mocks.updateConfigMock,
|
||||
]);
|
||||
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
|
||||
mockUseGetPostgresSettingsQueryResponse({ retention: null }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<DatabasePiTRSettings />);
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
expect(screen.getByRole('checkbox')).toBeChecked();
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Save',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mocks.updateConfigMock.mock.calls[0][0].variables.config.postgres.pitr,
|
||||
).toStrictEqual({ retention: 7 });
|
||||
});
|
||||
|
||||
test('should send { pitr: null } when disabling PiTR', async () => {
|
||||
mocks.useCurrentOrg.mockImplementation(() =>
|
||||
getCurrentOrg({ isFree: false }),
|
||||
);
|
||||
mocks.useUpdateConfigMutation.mockImplementation(() => [
|
||||
mocks.updateConfigMock,
|
||||
]);
|
||||
mocks.useGetPostgresSettingsQuery.mockImplementation(() =>
|
||||
mockUseGetPostgresSettingsQueryResponse({ retention: 7 }),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<DatabasePiTRSettings />);
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
expect(screen.getByRole('checkbox')).not.toBeChecked();
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'Save',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
mocks.updateConfigMock.mock.calls[0][0].variables.config.postgres,
|
||||
).toStrictEqual({ pitr: null });
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { useDatabasePiTRSettings } from '@/features/orgs/hooks/useDatabasePiTRSettings/';
|
||||
import { useUpdateDatabasePiTRConfig } from '@/features/orgs/hooks/useUpdateDatabasePiTRConfig';
|
||||
import { UpgradeNotification } from '@/features/orgs/projects/database/settings/components/UpgradeNotification';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
|
||||
export default function DatabasePiTRSettings() {
|
||||
const { org } = useCurrentOrg();
|
||||
const {
|
||||
isPiTREnabled,
|
||||
setIsPiTREnabled,
|
||||
isSwitchDisabled,
|
||||
setIsNotSwitchTouched,
|
||||
} = useDatabasePiTRSettings();
|
||||
const { updatePiTRConfig, loading } = useUpdateDatabasePiTRConfig();
|
||||
|
||||
const isFreeProject = isEmptyValue(org) ? false : org.plan.isFree;
|
||||
const shouldShowSwitch = isEmptyValue(org) ? false : !isFreeProject;
|
||||
|
||||
function handleEnabledChange(enabled: boolean) {
|
||||
setIsPiTREnabled(enabled);
|
||||
setIsNotSwitchTouched(false);
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
updatePiTRConfig(isPiTREnabled);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsContainer
|
||||
title="Point-in-time recovery"
|
||||
description="Enable Point-in-Time recovery (PiTR). Available as an add-on for organizations on Pro, Team, or Enterprise plans."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: isSwitchDisabled,
|
||||
onClick: handleSubmit,
|
||||
loading,
|
||||
type: 'button',
|
||||
},
|
||||
}}
|
||||
className="flex flex-col lg:flex-row"
|
||||
showSwitch={shouldShowSwitch}
|
||||
enabled={isPiTREnabled}
|
||||
onEnabledChange={handleEnabledChange}
|
||||
docsLink="https://docs.nhost.io/product/database#point-in-time-recovery"
|
||||
docsTitle="enabling or disabling PiTR"
|
||||
>
|
||||
{isFreeProject && (
|
||||
<UpgradeNotification description="To unlock this add-on, transfer this project to a Pro or Team organization." />
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatabasePiTRSettings } from './DatabasePiTRSettings';
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ApplyLocalSettingsDialog } from '@/components/common/ApplyLocalSettingsDialog';
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { ControlledAutocomplete } from '@/components/form/ControlledAutocomplete';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
@@ -15,7 +14,6 @@ import { DatabaseMigrateDisabledError } from '@/features/orgs/projects/database/
|
||||
import { DatabaseMigrateDowntimeWarning } from '@/features/orgs/projects/database/settings/components/DatabaseMigrateDowntimeWarning';
|
||||
import { DatabaseMigrateLogsModal } from '@/features/orgs/projects/database/settings/components/DatabaseMigrateLogsModal';
|
||||
import { DatabaseMigrateVersionConfirmationDialog } from '@/features/orgs/projects/database/settings/components/DatabaseMigrateVersionConfirmationDialog';
|
||||
import { DatabaseUpdateInProgressWarning } from '@/features/orgs/projects/database/settings/components/DatabaseUpdateInProgressWarning';
|
||||
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
@@ -28,9 +26,11 @@ import {
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
|
||||
import { splitPostgresMajorMinorVersions } from '@/features/orgs/projects/database/settings/utils/splitPostgresMajorMinorVersions';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
@@ -44,7 +44,11 @@ const validationSchema = Yup.object({
|
||||
})
|
||||
.label('Postgres major version')
|
||||
.required()
|
||||
.test('not-zero', 'Invalid major version', (value) => value?.value !== '0'),
|
||||
.test(
|
||||
'must-be-positive-number',
|
||||
'Invalid major version',
|
||||
(value) => Number(value?.value) > 0,
|
||||
),
|
||||
minorVersion: Yup.object({
|
||||
label: Yup.string().required(),
|
||||
value: Yup.string().required('Minor version is a required field'),
|
||||
@@ -64,7 +68,6 @@ type DatabaseServiceField = Required<
|
||||
export default function DatabaseServiceVersionSettings() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { openDialog, closeDialog } = useDialog();
|
||||
const { maintenanceActive } = useUI();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { project } = useProject();
|
||||
const [updateConfig] = useUpdateConfigMutation({
|
||||
@@ -77,8 +80,8 @@ export default function DatabaseServiceVersionSettings() {
|
||||
|
||||
const {
|
||||
version: postgresVersion,
|
||||
postgresMajor: currentPostgresMajor,
|
||||
postgresMinor: currentPostgresMinor,
|
||||
major: currentPostgresMajor,
|
||||
minor: currentPostgresMinor,
|
||||
error: postgresSettingsError,
|
||||
loading: loadingPostgresSettings,
|
||||
} = useGetPostgresVersion();
|
||||
@@ -124,7 +127,9 @@ export default function DatabaseServiceVersionSettings() {
|
||||
if (!availableVersion.value) {
|
||||
return;
|
||||
}
|
||||
const [major, minor] = availableVersion.value.split('.');
|
||||
const { major, minor } = splitPostgresMajorMinorVersions(
|
||||
availableVersion.value,
|
||||
);
|
||||
|
||||
// Don't suggest versions that are lower than the current Postgres major version (can't downgrade)
|
||||
if (Number(major) < Number(currentPostgresMajor)) {
|
||||
@@ -142,10 +147,12 @@ export default function DatabaseServiceVersionSettings() {
|
||||
majorToMinorVersions[major] = [];
|
||||
}
|
||||
|
||||
majorToMinorVersions[major].push({
|
||||
label: minor,
|
||||
value: minor,
|
||||
});
|
||||
if (isNotEmptyValue(minor)) {
|
||||
majorToMinorVersions[major].push({
|
||||
label: minor,
|
||||
value: minor,
|
||||
});
|
||||
}
|
||||
});
|
||||
return {
|
||||
availableMajorVersions,
|
||||
@@ -206,13 +213,12 @@ export default function DatabaseServiceVersionSettings() {
|
||||
!applicationPaused &&
|
||||
!applicationPausing &&
|
||||
!applicationUpdating;
|
||||
|
||||
const isMajorVersionDirty = formState?.dirtyFields?.majorVersion;
|
||||
const isMinorVersionDirty = formState?.dirtyFields?.minorVersion;
|
||||
const isDirty = isMajorVersionDirty || isMinorVersionDirty;
|
||||
|
||||
const versionFieldsDisabled =
|
||||
applicationUpdating || applicationUnhealthy || maintenanceActive;
|
||||
const saveDisabled = versionFieldsDisabled || !isDirty;
|
||||
const majorVersionFieldDisabled = applicationUnhealthy;
|
||||
|
||||
const handleDatabaseServiceVersionsChange = async (
|
||||
formValues: DatabaseServiceVersionFormValues,
|
||||
@@ -313,7 +319,7 @@ export default function DatabaseServiceVersionSettings() {
|
||||
description="The version of Postgres to use."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: saveDisabled,
|
||||
disabled: !isDirty,
|
||||
loading: formState.isSubmitting,
|
||||
},
|
||||
}}
|
||||
@@ -341,7 +347,7 @@ export default function DatabaseServiceVersionSettings() {
|
||||
name="majorVersion"
|
||||
autoHighlight
|
||||
freeSolo
|
||||
disabled={versionFieldsDisabled}
|
||||
disabled={majorVersionFieldDisabled}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option || '';
|
||||
@@ -408,7 +414,6 @@ export default function DatabaseServiceVersionSettings() {
|
||||
name="minorVersion"
|
||||
autoHighlight
|
||||
freeSolo
|
||||
disabled={versionFieldsDisabled}
|
||||
getOptionLabel={(option) => {
|
||||
if (typeof option === 'string') {
|
||||
return option || '';
|
||||
@@ -448,7 +453,6 @@ export default function DatabaseServiceVersionSettings() {
|
||||
/>
|
||||
</Box>
|
||||
{showMigrateWarning && <DatabaseMigrateDowntimeWarning />}
|
||||
{applicationUpdating && <DatabaseUpdateInProgressWarning />}
|
||||
{applicationUnhealthy && !isMigrating && (
|
||||
<DatabaseMigrateDisabledError />
|
||||
)}
|
||||
|
||||
@@ -8,10 +8,10 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { DatabaseStorageCapacityWarning } from '@/features/orgs/projects/database/settings/components/DatabaseStorageCapacityWarning';
|
||||
import { UpgradeNotification } from '@/features/orgs/projects/database/settings/components/UpgradeNotification';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
@@ -46,6 +46,8 @@ export default function DatabaseStorageCapacity() {
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { project } = useProject();
|
||||
|
||||
const isFreeProject = !!org?.plan.isFree;
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
@@ -172,8 +174,8 @@ export default function DatabaseStorageCapacity() {
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{project.legacyPlan?.isFree && (
|
||||
<UpgradeNotification message="Unlock by upgrading your project to the Pro plan." />
|
||||
{isFreeProject && (
|
||||
<UpgradeNotification description="To unlock more storage capacity, transfer this project to a Pro or Team organization." />
|
||||
)}
|
||||
<Box className="grid grid-flow-row lg:grid-cols-5">
|
||||
<Input
|
||||
@@ -187,13 +189,13 @@ export default function DatabaseStorageCapacity() {
|
||||
</InputAdornment>
|
||||
}
|
||||
fullWidth
|
||||
disabled={project.legacyPlan?.isFree}
|
||||
disabled={isFreeProject}
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.capacity?.message)}
|
||||
helperText={formState.errors.capacity?.message}
|
||||
/>
|
||||
</Box>
|
||||
{!project.legacyPlan?.isFree && (
|
||||
{!isFreeProject && (
|
||||
<DatabaseStorageCapacityWarning
|
||||
state={state}
|
||||
decreasingSize={decreasingSize}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { ClockIcon } from '@/components/ui/v2/icons/ClockIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
|
||||
export default function DatabaseMigrateWarning() {
|
||||
return (
|
||||
<Alert severity="warning" className="flex flex-col gap-3 text-left">
|
||||
<Text className="flex items-center gap-1 font-semibold">
|
||||
<ClockIcon className="h-4 w-4" /> An update is in progress
|
||||
</Text>
|
||||
<Text>You can edit the version only after the update is complete.</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as DatabaseUpdateInProgressWarning } from './DatabaseUpdateInProgressWarning';
|
||||
@@ -0,0 +1,66 @@
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
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 { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
description: string;
|
||||
}
|
||||
|
||||
// P
|
||||
|
||||
function UpgradeNotification({ description }: Props) {
|
||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleTransferDialogOpen = () => setTransferProjectDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Alert className="flex w-full flex-col justify-between gap-4 lg:flex-row">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col space-y-2 lg:flex-row lg:space-x-2 lg:space-y-0">
|
||||
<Text className="text-left">Available with</Text>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<NhostIcon />
|
||||
<Text
|
||||
sx={{ color: 'primary.main' }}
|
||||
className="text-left font-semibold"
|
||||
>
|
||||
Nhost Pro & Team
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text component="span" className="max-w-[50ch] text-left">
|
||||
{description}
|
||||
</Text>
|
||||
</div>
|
||||
<Text className="flex flex-row items-center gap-4 self-end">
|
||||
<Link
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="whitespace-nowrap text-center font-medium"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
See all features
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
/>
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeNotification;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UpgradeNotification } from './UpgradeNotification';
|
||||
@@ -15,6 +15,9 @@ query GetPostgresSettings($appId: uuid!) {
|
||||
}
|
||||
enablePublicAccess
|
||||
}
|
||||
pitr {
|
||||
retention
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as splitPostgresMajorMinorVersions } from './splitPostgresMajorMinorVersions';
|
||||
@@ -0,0 +1,22 @@
|
||||
import splitPostgresMajorMinorVersions from './splitPostgresMajorMinorVersions';
|
||||
|
||||
test('can split a postgres version into major and minor', () => {
|
||||
const { major, minor } = splitPostgresMajorMinorVersions('15.10-20250131-1');
|
||||
|
||||
expect(major).toBe('15');
|
||||
expect(minor).toBe('10-20250131-1');
|
||||
});
|
||||
|
||||
test('can get major and minor versions from a postgres beta version', () => {
|
||||
const { major, minor } = splitPostgresMajorMinorVersions('17.2-0.0.0-beta1');
|
||||
|
||||
expect(major).toBe('17');
|
||||
expect(minor).toBe('2-0.0.0-beta1');
|
||||
});
|
||||
|
||||
test('gets only major version if no minor version is present', () => {
|
||||
const { major, minor } = splitPostgresMajorMinorVersions('15');
|
||||
|
||||
expect(major).toBe('15');
|
||||
expect(minor).toBe('');
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Splits a Postgres version string into major and minor versions.
|
||||
* @param version - The Postgres version string to split.
|
||||
* @returns An object containing the major and minor versions.
|
||||
* It returns an empty string for the minor version if no minor version is present.
|
||||
*/
|
||||
export default function splitPostgresMajorMinorVersions(version: string) {
|
||||
const splitIndex = version.indexOf('.');
|
||||
|
||||
if (splitIndex === -1) {
|
||||
return {
|
||||
major: version,
|
||||
minor: '',
|
||||
};
|
||||
}
|
||||
|
||||
const major = version.slice(0, splitIndex);
|
||||
const minor = version.slice(splitIndex + 1);
|
||||
|
||||
return {
|
||||
major,
|
||||
minor,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Avatar } from '@/components/ui/v2/Avatar';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { Deployment } from '@/types/application';
|
||||
import formatDistance from 'date-fns/formatDistance';
|
||||
import { formatDistance } from 'date-fns';
|
||||
|
||||
export interface DeploymentStatusMessageProps {
|
||||
deployment: Partial<Deployment>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user