Compare commits
7 Commits
feat/add-e
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d4d28627 | ||
|
|
34fdcb8863 | ||
|
|
78436ca29e | ||
|
|
ea6584614b | ||
|
|
4937c5e055 | ||
|
|
b5a3895e16 | ||
|
|
9b24807562 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
fix: update babel dependencies to address security audit vulnerabilities
|
||||
@@ -1,5 +1,20 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.25.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 34fdcb8: chore: add prettier plugins as devDependencies to root of monorepo
|
||||
- 4937c5e: fix: stop content overflowing in projects and database permissions page
|
||||
- 1542132: fix: update babel dependencies to address security audit vulnerabilities
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 78436ca: chore (dashboard): add tests and small updates to PiTR settings and restore page
|
||||
- b5a3895: chore (dashboard): update page context after each navigation
|
||||
- 9b24807: chore: fix link to PiTR documentation
|
||||
- ea65846: chore (dashboard): update nextjs to fix middleware exploit
|
||||
|
||||
## 2.17.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to create then delete a personal access token', async () => {
|
||||
test('should be able to create then delete a personal access token', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByRole('banner').getByRole('button').last().click();
|
||||
await page.getByRole('link', { name: /account settings/i }).click();
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
@@ -23,11 +14,9 @@ test.beforeEach(async () => {
|
||||
await page.waitForURL(AIRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete an Assistant', async () => {
|
||||
test('should create and delete an Assistant', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('link', { name: 'Assistants' }).click();
|
||||
|
||||
await expect(page.getByText(/no assistants are configured/i)).toBeVisible();
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
@@ -23,11 +15,9 @@ test.beforeEach(async () => {
|
||||
await page.waitForURL(AIRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete an Auto-Embeddings', async () => {
|
||||
test('should create and delete an Auto-Embeddings', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('test');
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
test('should be able to ban and unban a user', async ({ page }) => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test('should be able to ban and unban a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } 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 ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a user', async () => {
|
||||
test('should create a user', async ({ authenticatedNhostPage: page }) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -31,7 +17,9 @@ test('should create a user', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to create a user with an existing email', async () => {
|
||||
test('should not be able to create a user with an existing email', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to delete a user', async () => {
|
||||
test('should be able to delete a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -52,7 +41,9 @@ test('should be able to delete a user', async () => {
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to delete a user from the details page', async () => {
|
||||
test('should be able to delete a user from the details page', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } 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 ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to edit user roles from the details page', async () => {
|
||||
test('should be able to edit user roles from the details page', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to verify the email of a user', async () => {
|
||||
test('should be able to verify the email of a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -50,7 +38,9 @@ test('should be able to verify the email of a user', async () => {
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test('should be able to verify the phone number of a user', async () => {
|
||||
test('should be able to verify the phone number of a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
const phoneNumber = faker.phone.number();
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { navigateToProject, prepareTable } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a simple table', async () => {
|
||||
test('should create a simple table', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -57,7 +40,9 @@ test('should create a simple table', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with unique constraints', async () => {
|
||||
test('should create a table with unique constraints', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -86,7 +71,9 @@ test('should create a table with unique constraints', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with nullable columns', async () => {
|
||||
test('should create a table with nullable columns', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -115,7 +102,9 @@ test('should create a table with nullable columns', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with an identity column', async () => {
|
||||
test('should create a table with an identity column', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -148,7 +137,9 @@ test('should create a table with an identity column', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create table with foreign key constraint', async () => {
|
||||
test('should create table with foreign key constraint', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -221,7 +212,9 @@ test('should create table with foreign key constraint', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to create a table with a name that already exists', async () => {
|
||||
test('should not be able to create a table with a name that already exists', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { deleteTable, navigateToProject, prepareTable } from '@/e2e/utils';
|
||||
import { deleteTable, prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should delete a table', async () => {
|
||||
test('should delete a table', async ({ authenticatedNhostPage: page }) => {
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
@@ -65,7 +47,9 @@ test('should delete a table', async () => {
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
|
||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import {
|
||||
clickPermissionButton,
|
||||
navigateToProject,
|
||||
prepareTable,
|
||||
} from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { clickPermissionButton, prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions to select row', async () => {
|
||||
test('should create a table with role permissions to select row', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -79,7 +58,9 @@ test('should create a table with role permissions to select row', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions and a custom check to select rows', async () => {
|
||||
test('should create a table with role permissions and a custom check to select rows', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
|
||||
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { TEST_DASHBOARD_URL, TEST_PERSONAL_ORG_SLUG } from '@/e2e/env';
|
||||
import { type Page, test as base } from '@playwright/test';
|
||||
|
||||
export const AUTH_CONTEXT = 'e2e/.auth/user.json';
|
||||
|
||||
export const test = base.extend<{ authenticatedNhostPage: Page }>({
|
||||
authenticatedNhostPage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: AUTH_CONTEXT });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForURL(
|
||||
`${TEST_DASHBOARD_URL}/orgs/${TEST_PERSONAL_ORG_SLUG}/projects`,
|
||||
{ waitUntil: 'networkidle' },
|
||||
);
|
||||
await use(page);
|
||||
// update the context to get the new refresh token
|
||||
await page.context().storageState({ path: AUTH_CONTEXT });
|
||||
await page.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -1,15 +1,8 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { navigateToProject } from '../utils';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
|
||||
await page.goto('/');
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
@@ -17,11 +10,9 @@ test.beforeAll(async ({ browser }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should show the navtree with all links visible', async () => {
|
||||
test('should show the navtree with all links visible', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const navLocator = page.getByLabel('Navigation Tree');
|
||||
await expect(navLocator).toBeVisible();
|
||||
|
||||
@@ -42,16 +33,20 @@ test('should show the navtree with all links visible', async () => {
|
||||
'Settings',
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const linkName of links) {
|
||||
const link =
|
||||
linkName === 'Settings'
|
||||
? page.getByRole('link', { name: linkName }).first()
|
||||
: page.getByRole('link', { name: linkName });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(link).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("should show the project's region and subdomain", async () => {
|
||||
test("should show the project's region and subdomain", async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
|
||||
/frankfurt \(eu-central-1\)/i,
|
||||
);
|
||||
@@ -60,7 +55,9 @@ test("should show the project's region and subdomain", async () => {
|
||||
).toHaveText(/[a-z]{20}/i);
|
||||
});
|
||||
|
||||
test('should not have a GitHub repository connected', async () => {
|
||||
test('should not have a GitHub repository connected', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: /connect to github/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { navigateToProject } from '../utils';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`;
|
||||
await page.goto(runRoute);
|
||||
await page.waitForURL(runRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete a run service', async () => {
|
||||
test('should create and delete a run service', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Add service' }).first().click();
|
||||
await expect(page.getByText(/create a new service/i)).toBeVisible();
|
||||
await page.getByPlaceholder(/service name/i).click();
|
||||
|
||||
@@ -1,49 +1,23 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
} from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import { type Page, expect, test as teardown } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
teardown.beforeAll(async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
});
|
||||
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
teardown.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { expect, test as teardown } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
teardown.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
teardown.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
teardown(
|
||||
'clean up database tables',
|
||||
async ({ authenticatedNhostPage: page }) => {
|
||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||
|
||||
teardown('clean up database tables', async () => {
|
||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||
);
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||
);
|
||||
|
||||
const inputField = page.locator('[contenteditable]');
|
||||
await inputField.fill(`
|
||||
const inputField = page.locator('[contenteditable]');
|
||||
await inputField.fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
@@ -56,6 +30,7 @@ teardown('clean up database tables', async () => {
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||
await expect(page.getByText(/success/i)).toBeVisible();
|
||||
});
|
||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||
await expect(page.getByText(/success/i)).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
@@ -211,3 +212,9 @@ export async function clickPermissionButton({
|
||||
.locator('button')
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function gotoAuthURL(page) {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.24.0",
|
||||
"version": "2.25.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -87,7 +87,7 @@
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "^14.2.22",
|
||||
"next": "^14.2.25",
|
||||
"next-nprogress-bar": "^2.3.13",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -96,7 +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-day-picker": "9.6.3",
|
||||
"react-dom": "18.2.0",
|
||||
"react-error-boundary": "^4.0.13",
|
||||
"react-hook-form": "^7.53.0",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { isTZDate } from '@/components/common/TimePicker/time-picker-utils';
|
||||
import { render, screen, waitFor } from '@/tests/orgs/testUtils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { isBefore, startOfDay } from 'date-fns-v4';
|
||||
import { useState } from 'react';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import { vi } from 'vitest';
|
||||
import DateTimePicker, { type DateTimePickerProps } from './DateTimePicker';
|
||||
|
||||
vi.mock('@/utils/timezoneUtils', async () => {
|
||||
const actualTimezoneUtils = await vi.importActual<any>(
|
||||
'@/utils/timezoneUtils',
|
||||
);
|
||||
return {
|
||||
...actualTimezoneUtils,
|
||||
guessTimezone: () => 'Europe/Helsinki',
|
||||
};
|
||||
});
|
||||
|
||||
const earliestBackupDate = '2025-03-13T02:00:05.000Z';
|
||||
|
||||
function TestComponent(
|
||||
props: Omit<DateTimePickerProps, 'dateTime' | 'onDateTimeChange'>,
|
||||
) {
|
||||
const [dateTime, setDateTime] = useState(earliestBackupDate);
|
||||
|
||||
function isCalendarDayDisabled(date: Date | TZDate) {
|
||||
if (isTZDate(date)) {
|
||||
const utcDay = new Date(date.getTime()).toISOString();
|
||||
const tzDate = new TZDate(utcDay, date.timeZone);
|
||||
const earliestBackupDateInTz = new TZDate(
|
||||
earliestBackupDate,
|
||||
date.timeZone,
|
||||
);
|
||||
return isBefore(startOfDay(tzDate), startOfDay(earliestBackupDateInTz));
|
||||
}
|
||||
|
||||
return isBefore(
|
||||
startOfDay(new Date(date.getTime()).toISOString()),
|
||||
startOfDay(earliestBackupDate),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 data-testid="utcDate">{dateTime}</h1>
|
||||
<DateTimePicker
|
||||
{...props}
|
||||
isCalendarDayDisabled={isCalendarDayDisabled}
|
||||
dateTime={dateTime}
|
||||
onDateTimeChange={setDateTime}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DateTimePicker', () => {
|
||||
test('when the date changes datetime is emitted in utc string format', async () => {
|
||||
render(<TestComponent />);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: 'Select' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Next Month' }),
|
||||
);
|
||||
expect(screen.getByText('April 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('13'));
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '11');
|
||||
|
||||
const minutesInput = await screen.getByLabelText('Minutes');
|
||||
await user.type(minutesInput, '12');
|
||||
|
||||
const secondsInput = await screen.getByLabelText('Seconds');
|
||||
await user.type(secondsInput, '13');
|
||||
|
||||
user.click(await screen.getByRole('button', { name: 'Select' }));
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.queryByRole('button', { name: 'Select' }),
|
||||
).not.toBeInTheDocument(),
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('utcDate')).toHaveTextContent(
|
||||
'2025-04-13T08:12:13.000Z',
|
||||
);
|
||||
});
|
||||
|
||||
test('timezone can be changed and the calendar is updated', async () => {
|
||||
await waitFor(() => render(<TestComponent withTimezone />));
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
|
||||
|
||||
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByTestId('timezoneSettingsButton'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+02:00',
|
||||
);
|
||||
expect(await screen.getByText('12')).toBeDisabled();
|
||||
|
||||
await user.click(await screen.findByTestId('timezoneSettingsButton'));
|
||||
const tzInput = await screen.findByPlaceholderText('Search timezones...');
|
||||
expect(tzInput).toBeInTheDocument();
|
||||
|
||||
await user.type(tzInput, 'America/Chicago{ArrowDown}{Enter}');
|
||||
|
||||
expect(
|
||||
await screen.queryByPlaceholderText('Search timezones...'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC-05:00',
|
||||
);
|
||||
|
||||
const selectedDay = screen.getByText('12');
|
||||
expect(selectedDay).not.toBeDisabled();
|
||||
expect(await screen.getByText('11')).toBeDisabled();
|
||||
const gridCell = selectedDay.closest('[role="gridcell"]');
|
||||
expect(gridCell).toHaveClass('[&>button]:bg-primary');
|
||||
});
|
||||
|
||||
test('Displays the correct time zone offset when changing the selected date from standard time (ST) to daylight saving time (DST)', async () => {
|
||||
await waitFor(() => render(<TestComponent withTimezone />));
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
|
||||
|
||||
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByTestId('timezoneSettingsButton'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+02:00',
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Next Month' }),
|
||||
);
|
||||
|
||||
expect(screen.getByText('April 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('18'));
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+03:00',
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Previous Month' }),
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('21'));
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+02:00',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,6 @@ export interface DateTimePickerProps {
|
||||
align?: 'start' | 'center' | 'end';
|
||||
validateDateFn?: (date: Date) => string;
|
||||
}
|
||||
// in: UTC datetime
|
||||
// out: UTC dateTime
|
||||
|
||||
function DateTimePicker({
|
||||
dateTime,
|
||||
@@ -49,6 +47,10 @@ function DateTimePicker({
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [timezone, setTimezone] = useState(
|
||||
() => defaultTimezone || guessTimezone(),
|
||||
);
|
||||
|
||||
function emitNewDateTime() {
|
||||
onDateTimeChange(new Date(date.getTime()).toISOString());
|
||||
}
|
||||
@@ -73,6 +75,7 @@ function DateTimePicker({
|
||||
|
||||
function handleTimezoneChange(newTimezone: string) {
|
||||
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
|
||||
setTimezone(newTimezone);
|
||||
setDate(newDateWithTimezone);
|
||||
}
|
||||
|
||||
@@ -80,6 +83,7 @@ function DateTimePicker({
|
||||
if (!newOpenState) {
|
||||
if (withTimezone) {
|
||||
const tz = defaultTimezone || guessTimezone();
|
||||
setTimezone(tz);
|
||||
setDate(new TZDate(dateTime, tz));
|
||||
}
|
||||
setDate(parseISO(dateTime));
|
||||
@@ -92,6 +96,8 @@ function DateTimePicker({
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const selectedDateInUTC = new Date(date.getTime()).toISOString();
|
||||
|
||||
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
|
||||
|
||||
const errorText = validateDateFn?.(date);
|
||||
@@ -101,6 +107,7 @@ function DateTimePicker({
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
data-testid="dateTimePickerTrigger"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-between text-left font-normal',
|
||||
@@ -113,6 +120,7 @@ function DateTimePicker({
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<div className="flex">
|
||||
<div className="flex">
|
||||
@@ -120,8 +128,8 @@ function DateTimePicker({
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d) => handleSelect(d)}
|
||||
initialFocus
|
||||
disabled={isCalendarDayDisabled}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div>
|
||||
@@ -131,7 +139,7 @@ function DateTimePicker({
|
||||
{withTimezone && (
|
||||
<div className="border-t border-border p-3">
|
||||
<TimezoneSettings
|
||||
dateTime={dateTime}
|
||||
dateTime={selectedDateInUTC}
|
||||
onTimezoneChange={handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,16 +18,23 @@ function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
|
||||
setTimezone(tz.value);
|
||||
onTimezoneChange?.(tz.value);
|
||||
}
|
||||
|
||||
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
Timezone: {utcOffset}{' '}
|
||||
<span>Timezone: {utcOffset}</span>
|
||||
<TimezonePicker
|
||||
dateTime={dateTime}
|
||||
selectedTimezone={selectedTimezone}
|
||||
onTimezoneSelect={handleTimezoneSelect}
|
||||
button={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open timezone settings"
|
||||
data-testid="timezoneSettingsButton"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 dark:text-foreground" />
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function getArrowByType(
|
||||
}
|
||||
}
|
||||
|
||||
function isTZDate(date: Date | TZDate): date is TZDate {
|
||||
export function isTZDate(date: Date | TZDate): date is TZDate {
|
||||
return date instanceof TZDate;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,26 @@ interface Props {
|
||||
dateTime: string;
|
||||
}
|
||||
|
||||
function getOrderedTimezones(dateTime: string, selectedTimezone: string) {
|
||||
const [utcTimezone, browserTimezone, ...timezones] =
|
||||
createTimezoneOptions(dateTime);
|
||||
let orderedTimezones = [...timezones];
|
||||
if (
|
||||
selectedTimezone !== browserTimezone.value &&
|
||||
selectedTimezone !== 'UTC'
|
||||
) {
|
||||
const selectedTimezoneOption = timezones.find(
|
||||
(tz) => tz.value === selectedTimezone,
|
||||
);
|
||||
orderedTimezones = [
|
||||
selectedTimezoneOption,
|
||||
...timezones.filter((tz) => tz.value !== selectedTimezone),
|
||||
];
|
||||
}
|
||||
|
||||
return [utcTimezone, browserTimezone, ...orderedTimezones];
|
||||
}
|
||||
|
||||
function TimezonePicker({
|
||||
selectedTimezone,
|
||||
onTimezoneSelect,
|
||||
@@ -16,9 +36,10 @@ function TimezonePicker({
|
||||
dateTime,
|
||||
}: Props) {
|
||||
const timezoneOptions = useMemo(
|
||||
() => createTimezoneOptions(dateTime),
|
||||
[dateTime],
|
||||
() => getOrderedTimezones(dateTime, selectedTimezone),
|
||||
[dateTime, selectedTimezone],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualizedCombobox
|
||||
options={timezoneOptions}
|
||||
@@ -27,6 +48,7 @@ function TimezonePicker({
|
||||
searchPlaceholder="Search timezones..."
|
||||
button={button}
|
||||
side="right"
|
||||
width="370px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,20 +105,12 @@ function VirtualizedCommand<O extends Option>({
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedOption) {
|
||||
const option = filteredOptions.find(
|
||||
(opt) => opt.value === selectedOption,
|
||||
);
|
||||
if (option) {
|
||||
const index = filteredOptions.indexOf(option);
|
||||
setFocusedIndex(index);
|
||||
}
|
||||
}
|
||||
}, [selectedOption, filteredOptions, virtualizer]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={selectedOption}
|
||||
>
|
||||
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
||||
<CommandList
|
||||
ref={parentRef}
|
||||
@@ -145,7 +137,6 @@ function VirtualizedCommand<O extends Option>({
|
||||
filteredOptions[virtualOption.index].key ??
|
||||
filteredOptions[virtualOption.index].value
|
||||
}
|
||||
disabled={isKeyboardNavActive}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 w-full bg-transparent',
|
||||
focusedIndex === virtualOption.index &&
|
||||
|
||||
@@ -1,75 +1,174 @@
|
||||
'use client';
|
||||
/* eslint-disable react/no-unstable-nested-components */
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
DayPicker,
|
||||
type DayPickerProps,
|
||||
type StyledComponent,
|
||||
} from 'react-day-picker';
|
||||
'use client';
|
||||
|
||||
import { buttonVariants } from '@/components/ui/v3/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { DayPicker, type DayPickerProps } from 'react-day-picker';
|
||||
|
||||
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} />
|
||||
);
|
||||
export type CalendarProps = DayPickerProps & {
|
||||
/**
|
||||
* In the year view, the number of years to display at once.
|
||||
* @default 12
|
||||
*/
|
||||
yearRange?: number;
|
||||
|
||||
/**
|
||||
* Wether to show the year switcher in the caption.
|
||||
* @default true
|
||||
*/
|
||||
showYearSwitcher?: boolean;
|
||||
|
||||
monthsClassName?: string;
|
||||
monthCaptionClassName?: string;
|
||||
weekdaysClassName?: string;
|
||||
weekdayClassName?: string;
|
||||
monthClassName?: string;
|
||||
captionClassName?: string;
|
||||
captionLabelClassName?: string;
|
||||
buttonNextClassName?: string;
|
||||
buttonPreviousClassName?: string;
|
||||
navClassName?: string;
|
||||
monthGridClassName?: string;
|
||||
weekClassName?: string;
|
||||
dayClassName?: string;
|
||||
dayButtonClassName?: string;
|
||||
rangeStartClassName?: string;
|
||||
rangeEndClassName?: string;
|
||||
selectedClassName?: string;
|
||||
todayClassName?: string;
|
||||
outsideClassName?: string;
|
||||
disabledClassName?: string;
|
||||
rangeMiddleClassName?: string;
|
||||
hiddenClassName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom calendar component built on top of react-day-picker.
|
||||
* @param props The props for the calendar.
|
||||
* @default yearRange 12
|
||||
* @returns
|
||||
*/
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
numberOfMonths,
|
||||
...props
|
||||
}: DayPickerProps) {
|
||||
}: CalendarProps) {
|
||||
const monthsClassName = cn('relative flex', props.monthsClassName);
|
||||
const monthCaptionClassName = cn(
|
||||
'relative mx-10 flex h-7 items-center justify-center',
|
||||
props.monthCaptionClassName,
|
||||
);
|
||||
const weekdaysClassName = cn('flex flex-row', props.weekdaysClassName);
|
||||
const weekdayClassName = cn(
|
||||
'w-8 text-sm font-normal text-muted-foreground',
|
||||
props.weekdayClassName,
|
||||
);
|
||||
const monthClassName = cn('w-full', props.monthClassName);
|
||||
const captionClassName = cn(
|
||||
'relative flex items-center justify-center pt-1',
|
||||
props.captionClassName,
|
||||
);
|
||||
const captionLabelClassName = cn(
|
||||
'truncate text-sm font-medium',
|
||||
props.captionLabelClassName,
|
||||
);
|
||||
const buttonNavClassName = buttonVariants({
|
||||
variant: 'outline',
|
||||
className:
|
||||
'absolute h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
||||
});
|
||||
const buttonNextClassName = cn(
|
||||
buttonNavClassName,
|
||||
'right-0',
|
||||
props.buttonNextClassName,
|
||||
);
|
||||
const buttonPreviousClassName = cn(
|
||||
buttonNavClassName,
|
||||
'left-0',
|
||||
props.buttonPreviousClassName,
|
||||
);
|
||||
const navClassName = cn('flex items-start', props.navClassName);
|
||||
const monthGridClassName = cn('mx-auto mt-4', props.monthGridClassName);
|
||||
const weekClassName = cn('mt-2 flex w-max items-start', props.weekClassName);
|
||||
const dayClassName = cn(
|
||||
'flex size-8 flex-1 items-center justify-center p-0 text-sm',
|
||||
props.dayClassName,
|
||||
);
|
||||
const dayButtonClassName = cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'size-8 rounded-md p-0 font-normal transition-none aria-selected:opacity-100',
|
||||
props.dayButtonClassName,
|
||||
);
|
||||
const buttonRangeClassName =
|
||||
'bg-accent [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground';
|
||||
const rangeStartClassName = cn(
|
||||
buttonRangeClassName,
|
||||
'day-range-start rounded-s-md',
|
||||
props.rangeStartClassName,
|
||||
);
|
||||
const rangeEndClassName = cn(
|
||||
buttonRangeClassName,
|
||||
'day-range-end rounded-e-md',
|
||||
props.rangeEndClassName,
|
||||
);
|
||||
const rangeMiddleClassName = cn(
|
||||
'bg-accent !text-foreground [&>button]:bg-transparent [&>button]:!text-foreground [&>button]:hover:bg-transparent [&>button]:hover:!text-foreground',
|
||||
props.rangeMiddleClassName,
|
||||
);
|
||||
const selectedClassName = cn(
|
||||
'[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground',
|
||||
props.selectedClassName,
|
||||
);
|
||||
const todayClassName = cn(
|
||||
'[&>button]:bg-accent [&>button]:text-accent-foreground',
|
||||
props.todayClassName,
|
||||
);
|
||||
const outsideClassName = cn(
|
||||
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
|
||||
props.outsideClassName,
|
||||
);
|
||||
const disabledClassName = cn(
|
||||
'text-muted-foreground opacity-50',
|
||||
props.disabledClassName,
|
||||
);
|
||||
const hiddenClassName = cn('invisible flex-1', props.hiddenClassName);
|
||||
|
||||
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,
|
||||
months: monthsClassName,
|
||||
month_caption: monthCaptionClassName,
|
||||
weekdays: weekdaysClassName,
|
||||
weekday: weekdayClassName,
|
||||
month: monthClassName,
|
||||
caption: captionClassName,
|
||||
caption_label: captionLabelClassName,
|
||||
button_next: buttonNextClassName,
|
||||
button_previous: buttonPreviousClassName,
|
||||
nav: navClassName,
|
||||
month_grid: monthGridClassName,
|
||||
week: weekClassName,
|
||||
day: dayClassName,
|
||||
day_button: dayButtonClassName,
|
||||
range_start: rangeStartClassName,
|
||||
range_middle: rangeMiddleClassName,
|
||||
range_end: rangeEndClassName,
|
||||
selected: selectedClassName,
|
||||
today: todayClassName,
|
||||
outside: outsideClassName,
|
||||
disabled: disabledClassName,
|
||||
hidden: hiddenClassName,
|
||||
}}
|
||||
components={{
|
||||
IconLeft,
|
||||
IconRight,
|
||||
Chevron: ({ orientation }) => {
|
||||
const Icon = orientation === 'left' ? ChevronLeft : ChevronRight;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -35,16 +35,23 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandInputProps {
|
||||
prefix?: React.ReactNode;
|
||||
prefixClassName?: string;
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
|
||||
prefix?: React.ReactNode;
|
||||
}
|
||||
>(({ className, prefix, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> &
|
||||
CommandInputProps
|
||||
>(({ className, prefix, prefixClassName, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
{prefix && (
|
||||
<span className="pointer-events-none flex items-center text-muted-foreground">
|
||||
<span
|
||||
title={prefix}
|
||||
className={cn('text-muted-foreground', prefixClassName)}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -5,24 +5,33 @@ import { type PropsWithChildren, type ReactNode } from 'react';
|
||||
interface Props {
|
||||
title?: string;
|
||||
icon?: ReactNode;
|
||||
borderLess?: boolean;
|
||||
}
|
||||
|
||||
function InfoAlert({ children, title, icon }: PropsWithChildren<Props>) {
|
||||
function InfoAlert({
|
||||
children,
|
||||
title,
|
||||
icon,
|
||||
borderLess = false,
|
||||
}: PropsWithChildren<Props>) {
|
||||
const alertClassNames = cn('bg-[#ebf3ff] dark:bg-muted', {
|
||||
'flex gap-2 items-center': !!icon,
|
||||
'border-none': borderLess,
|
||||
});
|
||||
|
||||
const descClassNames = cn('text-[0.9375rem] leading-[22px]', {
|
||||
'text-[0.875rem] leading-[1rem]': !!icon,
|
||||
const descClassNames = cn('text-[0.9375rem] leading-6', {
|
||||
'text-[0.875rem] leading-6': !!icon,
|
||||
});
|
||||
return (
|
||||
<Alert className={alertClassNames}>
|
||||
{icon && <div>{icon}</div>}
|
||||
<div>
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
<AlertDescription className={descClassNames}>
|
||||
{children}
|
||||
</AlertDescription>
|
||||
{children && (
|
||||
<AlertDescription className={descClassNames}>
|
||||
{children}
|
||||
</AlertDescription>
|
||||
)}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
|
||||
@@ -57,6 +57,7 @@ const getUseRouterObject = (session_id?: string) => ({
|
||||
},
|
||||
isFallback: false,
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useRouter: vi.fn(),
|
||||
useOrgs: vi.fn(),
|
||||
|
||||
@@ -27,8 +27,10 @@ function ProjectCard({ project }: { project: Project }) {
|
||||
>
|
||||
<div className="flex flex-row items-start gap-2">
|
||||
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
|
||||
<div className="flex w-full flex-col">
|
||||
<p className="truncate font-bold">{project.name}</p>
|
||||
<div className="flex w-full flex-col overflow-hidden">
|
||||
<p title={project.name} className="truncate font-bold">
|
||||
{project.name}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{project.region.name}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { TabsContent } from '@/components/ui/v3/tabs';
|
||||
import { useIsPiTREnabled } from '@/features/orgs/hooks/useIsPiTREnabled';
|
||||
import {
|
||||
getPiTRNotEnabledPostgresSettings,
|
||||
getPostgresSettings,
|
||||
} from '@/tests/msw/mocks/graphql/getPostgresSettings';
|
||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { render, screen } from '@/tests/orgs/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { vi } from 'vitest';
|
||||
import BackupsContent from './BackupsContent';
|
||||
|
||||
function TestComponent() {
|
||||
const { isPiTREnabled, loading } = useIsPiTREnabled();
|
||||
if (loading) {
|
||||
return <h1>Loading...</h1>;
|
||||
}
|
||||
return <BackupsContent isPiTREnabled={isPiTREnabled} />;
|
||||
}
|
||||
|
||||
vi.mock(
|
||||
'@/features/orgs/projects/backups/components/ScheduledBackupTabContent',
|
||||
() => ({
|
||||
ScheduledBackupTabContent: () => (
|
||||
<TabsContent value="scheduledBackups">
|
||||
<h1>Scheduled backups is loaded</h1>
|
||||
</TabsContent>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
vi.mock(
|
||||
'@/features/orgs/projects/backups/components/PointInTimeTabsContent',
|
||||
() => ({
|
||||
PointInTimeTabsContent: () => (
|
||||
<TabsContent value="pointInTime">
|
||||
<h1>PiTR tab is loaded</h1>
|
||||
</TabsContent>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
|
||||
describe('BackupsContent', () => {
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('that Scheduled backups tab is loaded when PiTR is not enabled', async () => {
|
||||
server.use(getPiTRNotEnabledPostgresSettings);
|
||||
server.use(getProjectQuery);
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.findByText('Scheduled backups is loaded'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('that Point-in-Time tab is loaded when PiTR is enabled', async () => {
|
||||
server.use(getPostgresSettings);
|
||||
server.use(getProjectQuery);
|
||||
render(<TestComponent />);
|
||||
expect(await screen.findByText('PiTR tab is loaded')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,12 @@ 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="pointInTime">Point-in-Time</TabsTrigger>
|
||||
<TabsTrigger value="importBackup">Import backup</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="pt-7">
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import {
|
||||
fetchPiTRBaseBackups,
|
||||
mockApplication,
|
||||
mockMatchMediaValue,
|
||||
} from '@/tests/mocks';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import {
|
||||
mockPointerEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@/tests/orgs/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { Tabs } from '@/components/ui/v3/tabs';
|
||||
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||
import {
|
||||
getPiTRNotEnabledPostgresSettings,
|
||||
getPostgresSettings,
|
||||
} from '@/tests/msw/mocks/graphql/getPostgresSettings';
|
||||
import {
|
||||
getEmptyProjectsQuery,
|
||||
getProjectsQuery,
|
||||
} from '@/tests/msw/mocks/graphql/getProjectsQuery';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import ImportBackupContent from './ImportBackupTabContent';
|
||||
|
||||
function TestComponent() {
|
||||
return (
|
||||
<Tabs value="importBackup">
|
||||
<ImportBackupContent />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
mockPointerEvent();
|
||||
|
||||
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('ImportBackupContent', () => {
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("will display the target project's name and a select with the projects from the same region", async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectsQuery);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.getByText(
|
||||
`${mockApplication.name} (${mockApplication.region.name})`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const projectComboBox = await screen.findByRole('combobox');
|
||||
|
||||
await user.click(projectComboBox);
|
||||
// check for only projects from the same region are listed
|
||||
expect(screen.getByRole('option', { name: /pitr14/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('option', { name: /pitr-not-enabled/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('option', { name: /pitr-test/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('option', { name: /pitr-region-test-eu/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('that warning is displayed if there are no other projects in the same organization', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getEmptyProjectsQuery);
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
expect(
|
||||
await screen.findByText(
|
||||
/There are no other projects within the region:/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('will schedule an import from the selected project', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectsQuery);
|
||||
server.use(getPostgresSettings);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.getByText(
|
||||
`${mockApplication.name} (${mockApplication.region.name})`,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const projectComboBox = await screen.findByRole('combobox');
|
||||
|
||||
await user.click(projectComboBox);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('option', {
|
||||
name: 'pitr14 (us-east-1)',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.getByText('Import backup from pitr14 (us-east-1)'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
const startImportButton = await screen.getByRole('button', {
|
||||
name: 'Start import',
|
||||
});
|
||||
|
||||
await user.click(startImportButton);
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Import 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.getByText('13'));
|
||||
|
||||
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)',
|
||||
);
|
||||
|
||||
// check checkboxes
|
||||
|
||||
await user.click(
|
||||
await screen.getByLabelText(/I understand that restoring this backup/),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.getByLabelText(/I understand this cannot be undone/),
|
||||
);
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
await screen.getByRole('button', { name: 'Import backup' }),
|
||||
).not.toBeDisabled(),
|
||||
);
|
||||
|
||||
await user.click(
|
||||
await screen.getByRole('button', { name: 'Import backup' }),
|
||||
);
|
||||
|
||||
expect(mocks.restoreApplicationDatabase.mock.calls[0][0].fromAppId).toBe(
|
||||
'pitr14-id',
|
||||
);
|
||||
|
||||
expect(
|
||||
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
|
||||
).toBe('2025-03-13T16:00:05.000Z');
|
||||
});
|
||||
// TODO
|
||||
test('Pitr is not enabled on project', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectsQuery);
|
||||
server.use(getPiTRNotEnabledPostgresSettings);
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<TestComponent />);
|
||||
|
||||
const projectComboBox = await screen.findByRole('combobox');
|
||||
|
||||
await user.click(projectComboBox);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('option', {
|
||||
name: 'pitr-not-enabled-usa (us-east-1)',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Point-in-Time Recovery is not enabled on the selected project',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { DatabaseZap } from 'lucide-react';
|
||||
function PiTRNotEnabledOnSourceProject() {
|
||||
return (
|
||||
<InfoAlert
|
||||
title="Point-in-Time recovery is not enabled on the selected project"
|
||||
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!
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { PointInTimeBackupInfo } from '@/features/orgs/projects/backups/components/common/PointInTimeBackupInfo';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import RecoveryRetentionPeriod from './RecoveryRetentionPeriod';
|
||||
import RestoreRecommendationNote from './RestoreRecommendationNote';
|
||||
|
||||
function PointInTimeRecovery() {
|
||||
const { project } = useProject();
|
||||
return (
|
||||
<div className="flex flex-col gap-[1.875rem]">
|
||||
<RecoveryRetentionPeriod />
|
||||
<PointInTimeBackupInfo appId={project?.id} />
|
||||
<RestoreRecommendationNote />
|
||||
<PointInTimeBackupInfo appId={project?.id} showLink />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Tabs } from '@/components/ui/v3/tabs';
|
||||
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||
import {
|
||||
getPiTRNotEnabledPostgresSettings,
|
||||
getPostgresSettings,
|
||||
} from '@/tests/msw/mocks/graphql/getPostgresSettings';
|
||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { render, screen } from '@/tests/orgs/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { vi } from 'vitest';
|
||||
import PointInTimeTabsContent from './PointInTimeTabsContent';
|
||||
|
||||
function TestComponent() {
|
||||
return (
|
||||
<Tabs value="pointInTime">
|
||||
<PointInTimeTabsContent />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock('./PointInTimeRecovery', () => ({
|
||||
default: () => <h1>PiTR enabled</h1>,
|
||||
}));
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
|
||||
describe('PointInTimeTabsContent', () => {
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('if Point-in-Time Recovery is enabled', async () => {
|
||||
server.use(getPostgresSettings);
|
||||
server.use(getProjectQuery);
|
||||
render(<TestComponent />);
|
||||
expect(await screen.findByText('PiTR enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('if Point-in-sTime Recovery is not enabled', async () => {
|
||||
server.use(getOrganization);
|
||||
server.use(getProjectQuery);
|
||||
server.use(getPiTRNotEnabledPostgresSettings);
|
||||
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.findByText(/To enable Point-in-Time recovery/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||
import { ShieldAlertIcon } from 'lucide-react';
|
||||
|
||||
function RestoreRecommendationNote() {
|
||||
return (
|
||||
<InfoAlert icon={<ShieldAlertIcon className="h-[38px] w-[38px]" />}>
|
||||
<p className="!leading-[1.5]">
|
||||
We recommend importing the backup to a different project first to ensure
|
||||
everything works as expected before applying it to this project.
|
||||
</p>
|
||||
</InfoAlert>
|
||||
);
|
||||
}
|
||||
|
||||
export default RestoreRecommendationNote;
|
||||
@@ -3,8 +3,9 @@ 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.
|
||||
With Point-in-Time Recovery enabled, Scheduled backups are no longer
|
||||
taken. Point-in-Time Recovery provides more precise recovery, making
|
||||
additional backups unnecessary.
|
||||
</InfoAlert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Tabs } from '@/components/ui/v3/tabs';
|
||||
import {
|
||||
getPiTRNotEnabledPostgresSettings,
|
||||
getPostgresSettings,
|
||||
} from '@/tests/msw/mocks/graphql/getPostgresSettings';
|
||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { render, screen } from '@/tests/orgs/testUtils';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { vi } from 'vitest';
|
||||
import ScheduledBackupTabContent from './ScheduledBackupTabContent';
|
||||
|
||||
function TestComponent() {
|
||||
return (
|
||||
<Tabs value="scheduledBackups">
|
||||
<ScheduledBackupTabContent />
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock('./BackupList', () => ({
|
||||
default: () => <h1>Backup list</h1>,
|
||||
}));
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
|
||||
describe('ScheduledBackupTabContent', () => {
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('that Scheduled backups is loaded if PiTR is not enabled', async () => {
|
||||
server.use(getPiTRNotEnabledPostgresSettings);
|
||||
server.use(getProjectQuery);
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.findByText(/The database backup includes database schema/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('that a warning message is displayed if Point-in-Time Recovery is enabled ', async () => {
|
||||
server.use(getProjectQuery);
|
||||
server.use(getPostgresSettings);
|
||||
|
||||
render(<TestComponent />);
|
||||
expect(
|
||||
await screen.findByText(/With Point-in-Time Recovery enabled/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,8 @@
|
||||
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 TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
|
||||
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;
|
||||
@@ -29,10 +15,10 @@ function BackupScheduledInfo({ onClose, orgSlug, subdomain }: Props) {
|
||||
<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`}>
|
||||
<TextLink href={`/orgs/${orgSlug}/projects/${subdomain}/logs`}>
|
||||
Logs page
|
||||
</LogsLink>{' '}
|
||||
and select the service "Backup Job" to see the restore logs.
|
||||
</TextLink>{' '}
|
||||
and select the service "Backup Jobs" to see the restore logs.
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={onClose}>
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { test, vi } from 'vitest';
|
||||
|
||||
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||
@@ -64,37 +64,62 @@ describe('PointInTimeBackupInfo', () => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('will fetch the earliest backup and will display the date in with timezone', async () => {
|
||||
server.use(getOrganization);
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
test("will display the earliest backup's date with the local timezone", async () => {
|
||||
server.use(getProjectQuery);
|
||||
server.use(getOrganization);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
||||
// '10 March 2025, 05:00:05 (UTC+02:00)'
|
||||
await waitFor(() =>
|
||||
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
|
||||
);
|
||||
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 () => {
|
||||
test("that the system fetches the earliest backup, displays 'Project has no backups yet' message when no backups exist, and verifies that the restore button is disabled.", async () => {
|
||||
server.use(getOrganization);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchEmptyPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() =>
|
||||
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
|
||||
);
|
||||
|
||||
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 update the date after the timezone has 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)',
|
||||
@@ -116,29 +141,8 @@ describe('PointInTimeBackupInfo', () => {
|
||||
);
|
||||
});
|
||||
|
||||
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 },
|
||||
@@ -167,11 +171,8 @@ describe('PointInTimeBackupInfo', () => {
|
||||
await screen.getByRole('button', { name: 'Select' }),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
await user.click(
|
||||
await screen.getByRole('gridcell', {
|
||||
name: /13/i,
|
||||
}),
|
||||
);
|
||||
|
||||
await user.click(await screen.getByText('13'));
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '18');
|
||||
@@ -236,5 +237,121 @@ describe('PointInTimeBackupInfo', () => {
|
||||
expect(
|
||||
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
|
||||
).toBe('2025-03-13T16:00:05.000Z');
|
||||
|
||||
// call the onCompleted cb
|
||||
await waitFor(() => mocks.restoreApplicationDatabase.mock.calls[0][1]());
|
||||
expect(
|
||||
screen.getByText('Backup has been scheduled successfully.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('that dates before the earliest backup cannot be selected', async () => {
|
||||
server.use(getOrganization);
|
||||
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(),
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
expect(await screen.getByText('9')).toBeDisabled();
|
||||
|
||||
expect(await screen.getAllByText('1')[0]).toBeDisabled();
|
||||
|
||||
expect(await screen.getAllByText('5')[0]).toBeDisabled();
|
||||
|
||||
expect(await screen.getByText('10')).not.toBeDisabled();
|
||||
|
||||
expect(await screen.getByText('15')).not.toBeDisabled();
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '{ArrowDown}');
|
||||
|
||||
expect(await screen.getByLabelText('Hours')).toHaveValue('04');
|
||||
|
||||
const updatedDateTimeButton = await screen.getByRole('button', {
|
||||
name: /UTC/i,
|
||||
});
|
||||
expect(updatedDateTimeButton).toHaveTextContent(
|
||||
'10 Mar 2025, 04:00:05 (UTC+02:00)',
|
||||
);
|
||||
expect(
|
||||
await screen.queryByRole('button', { name: 'Select' }),
|
||||
).toBeDisabled();
|
||||
|
||||
expect(
|
||||
await screen.queryByText(
|
||||
'Selected date and time is before the earliest available backup',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.getByRole('button', {
|
||||
name: /UTC/i,
|
||||
}),
|
||||
).toHaveClass('border-destructive');
|
||||
});
|
||||
|
||||
test('Learn more link is displayed in the "footer" and aligned to the left and the "Start restore" button is to the right', async () => {
|
||||
server.use(getOrganization);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() =>
|
||||
render(<PointInTimeBackupInfo appId={mockApplication.id} showLink />),
|
||||
);
|
||||
const learMoreAboutPiTRLink = screen.getByText(
|
||||
/Learn more about Point-in-Time Recover/,
|
||||
);
|
||||
expect(learMoreAboutPiTRLink).toBeInTheDocument();
|
||||
const linkWrapper = learMoreAboutPiTRLink.closest('div');
|
||||
expect(linkWrapper).toHaveClass('justify-between');
|
||||
expect(linkWrapper).not.toHaveClass('justify-end');
|
||||
});
|
||||
|
||||
test('Learn more link is in not displayed in the "footer" the "Start restore" button is aligned to the right', async () => {
|
||||
server.use(getOrganization);
|
||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||
fetchPiTRBaseBackups,
|
||||
{ loading: false },
|
||||
]);
|
||||
|
||||
await waitFor(() =>
|
||||
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText(/Learn more about Point-in-Time Recover/),
|
||||
).not.toBeInTheDocument();
|
||||
const startRestoreButton = screen.getByText('Start restore');
|
||||
const linkWrapper = startRestoreButton.closest('div');
|
||||
expect(linkWrapper).not.toHaveClass('justify-between');
|
||||
expect(linkWrapper).toHaveClass('justify-end');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
import usePiTRBaseBackups from '@/features/orgs/hooks/usePiTRBaseBackups/usePiTRBaseBackups';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { Info } from 'lucide-react';
|
||||
import { cn, isEmptyValue } from '@/lib/utils';
|
||||
import { Info, SquareArrowUpRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import EarliestBackup from './EarliestBackup';
|
||||
import RestoreBackupDialogButton from './RestoreBackupDialogButton';
|
||||
|
||||
function LearnMoreAboutPiTRLink() {
|
||||
return (
|
||||
<Link
|
||||
href="https://docs.nhost.io/guides/database/backups#point-in-time-recovery"
|
||||
className="flex items-center gap-1 text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Learn more about Point-in-Time Recovery{' '}
|
||||
<SquareArrowUpRightIcon className="h-4 w-4" />
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
appId: string;
|
||||
title?: string;
|
||||
dialogTitle?: string;
|
||||
dialogButtonText?: string;
|
||||
dialogTriggerText?: string;
|
||||
showLink?: boolean;
|
||||
}
|
||||
|
||||
function PointInTimeBackupInfo({
|
||||
@@ -18,12 +34,13 @@ function PointInTimeBackupInfo({
|
||||
dialogTitle = 'Recover your database from a backup',
|
||||
dialogButtonText,
|
||||
dialogTriggerText,
|
||||
showLink = false,
|
||||
}: 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]">
|
||||
@@ -46,7 +63,13 @@ function PointInTimeBackupInfo({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-end border-t border-[#EAEDF0] p-4 dark:border-[#2F363D]">
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full items-center border-t border-[#EAEDF0] p-4 dark:border-[#2F363D]',
|
||||
{ 'justify-between': showLink, 'justify-end': !showLink },
|
||||
)}
|
||||
>
|
||||
{showLink && <LearnMoreAboutPiTRLink />}
|
||||
<RestoreBackupDialogButton
|
||||
disabled={disableStartRestoreButton}
|
||||
earliestBackupDate={earliestBackupDate}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { DateTimePicker } from '@/components/common/DateTimePicker';
|
||||
import { isTZDate } from '@/components/common/TimePicker/time-picker-utils';
|
||||
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
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 { 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';
|
||||
@@ -77,8 +78,21 @@ function RestoreBackupDialogButton({
|
||||
return format(date, 'dd MMM yyyy, HH:mm:ss (OOOO)').replace('GMT', 'UTC');
|
||||
}
|
||||
|
||||
function isCalendarDayDisabled(date: Date) {
|
||||
return isBefore(startOfDay(date), startOfDay(earliestBackupDate));
|
||||
function isCalendarDayDisabled(date: Date | TZDate) {
|
||||
if (isTZDate(date)) {
|
||||
const utcDay = new Date(date.getTime()).toISOString();
|
||||
const tzDate = new TZDate(utcDay, date.timeZone);
|
||||
const earliestBackupDateInTz = new TZDate(
|
||||
earliestBackupDate,
|
||||
date.timeZone,
|
||||
);
|
||||
return isBefore(startOfDay(tzDate), startOfDay(earliestBackupDateInTz));
|
||||
}
|
||||
|
||||
return isBefore(
|
||||
startOfDay(new Date(date.getTime()).toISOString()),
|
||||
startOfDay(earliestBackupDate),
|
||||
);
|
||||
}
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import Link from 'next/link';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
function TextLink({
|
||||
href,
|
||||
children,
|
||||
target = '_blank',
|
||||
}: PropsWithChildren<{ href: string; target?: string }>) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
|
||||
target={target}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextLink;
|
||||
@@ -198,24 +198,33 @@ function ColumnAutocomplete(
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTrigger
|
||||
asChild
|
||||
title={
|
||||
buttonPrefix
|
||||
? `${buttonPrefix}.${selectedColumn?.label}`
|
||||
: selectedColumn?.label || 'Select a column'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
ref={ref}
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{buttonPrefix ? (
|
||||
<div className="flex flex-shrink-0 gap-0 truncate">
|
||||
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||
<div className="flex min-w-0 flex-shrink items-center gap-0">
|
||||
<span className="flex-shrink truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||
{buttonPrefix}.
|
||||
</span>
|
||||
{selectedColumn?.label}
|
||||
<span className="truncate">{selectedColumn?.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
selectedColumn?.label || 'Select a column'
|
||||
<span className="truncate">
|
||||
{selectedColumn?.label || 'Select a column'}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -244,10 +253,11 @@ function ColumnAutocomplete(
|
||||
placeholder=""
|
||||
prefix={
|
||||
relationshipDotNotation
|
||||
? `
|
||||
${selectedTable}.${relationshipDotNotation}.`
|
||||
: ``
|
||||
? `${selectedTable}.${relationshipDotNotation}.`
|
||||
: ''
|
||||
}
|
||||
className="w-auto min-w-0 flex-grow items-center gap-0 pl-0"
|
||||
prefixClassName="flex-shrink truncate max-w-[200px]"
|
||||
/>
|
||||
{pages?.length > 0 ? (
|
||||
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
|
||||
@@ -286,7 +296,10 @@ function ColumnAutocomplete(
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<span className="line-clamp-2 break-all">
|
||||
<span
|
||||
title={option.label}
|
||||
className="line-clamp-2 break-all"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
@@ -330,7 +343,10 @@ function ColumnAutocomplete(
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<span className="line-clamp-2 break-all">
|
||||
<span
|
||||
title={option.label}
|
||||
className="line-clamp-2 break-all"
|
||||
>
|
||||
{option.label}
|
||||
</span>
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||
import { useDatabasePiTRSettings } from '@/features/orgs/hooks/useDatabasePiTRSettings/';
|
||||
import { useUpdateDatabasePiTRConfig } from '@/features/orgs/hooks/useUpdateDatabasePiTRConfig';
|
||||
import TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
|
||||
import { UpgradeNotification } from '@/features/orgs/projects/database/settings/components/UpgradeNotification';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
@@ -29,8 +31,8 @@ export default function DatabasePiTRSettings() {
|
||||
|
||||
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."
|
||||
title="Point-in-Time Recovery"
|
||||
description="Enable Point-in-Time Recovery (PiTR)."
|
||||
slotProps={{
|
||||
submitButton: {
|
||||
disabled: isSwitchDisabled,
|
||||
@@ -43,11 +45,19 @@ export default function DatabasePiTRSettings() {
|
||||
showSwitch={shouldShowSwitch}
|
||||
enabled={isPiTREnabled}
|
||||
onEnabledChange={handleEnabledChange}
|
||||
docsLink="https://docs.nhost.io/product/database#point-in-time-recovery"
|
||||
docsLink="https://docs.nhost.io/guides/database/backups#point-in-time-recovery"
|
||||
docsTitle="enabling or disabling PiTR"
|
||||
>
|
||||
{isFreeProject && (
|
||||
{isFreeProject ? (
|
||||
<UpgradeNotification description="To unlock this add-on, transfer this project to a Pro or Team organization." />
|
||||
) : (
|
||||
<InfoAlert borderLess>
|
||||
Available as an add-on for organizations on Pro, Team, or Enterprise
|
||||
plans for <strong>$100 per month.</strong>{' '}
|
||||
<TextLink href="https://nhost.io/pricing">
|
||||
View pricing details
|
||||
</TextLink>
|
||||
</InfoAlert>
|
||||
)}
|
||||
</SettingsContainer>
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ export const mockApplication: Project = {
|
||||
name: 'Test Application',
|
||||
slug: 'test-application',
|
||||
appStates: [],
|
||||
subdomain: '',
|
||||
subdomain: 'subdomain',
|
||||
region: {
|
||||
name: 'us-east-1',
|
||||
city: 'New York',
|
||||
|
||||
69
dashboard/src/tests/msw/mocks/graphql/getPostgresSettings.ts
Normal file
69
dashboard/src/tests/msw/mocks/graphql/getPostgresSettings.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export const getPostgresSettings = nhostGraphQLLink.query(
|
||||
'GetPostgresSettings',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
systemConfig: {
|
||||
postgres: {
|
||||
database: 'gnlivtcgjxctuujxpslj',
|
||||
__typename: 'ConfigSystemConfigPostgres',
|
||||
},
|
||||
__typename: 'ConfigSystemConfig',
|
||||
},
|
||||
config: {
|
||||
id: 'ConfigConfig',
|
||||
__typename: 'ConfigConfig',
|
||||
postgres: {
|
||||
version: '14.15-20250311-rc2',
|
||||
resources: {
|
||||
storage: {
|
||||
capacity: 1,
|
||||
__typename: 'ConfigPostgresResourcesStorage',
|
||||
},
|
||||
enablePublicAccess: null,
|
||||
__typename: 'ConfigPostgresResources',
|
||||
},
|
||||
pitr: { retention: 7, __typename: 'ConfigPostgresPitr' },
|
||||
__typename: 'ConfigPostgres',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const getPiTRNotEnabledPostgresSettings = nhostGraphQLLink.query(
|
||||
'GetPostgresSettings',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
systemConfig: {
|
||||
postgres: {
|
||||
database: 'gnlivtcgjxctuujxpslj',
|
||||
__typename: 'ConfigSystemConfigPostgres',
|
||||
},
|
||||
__typename: 'ConfigSystemConfig',
|
||||
},
|
||||
config: {
|
||||
id: 'ConfigConfig',
|
||||
__typename: 'ConfigConfig',
|
||||
postgres: {
|
||||
version: '14.15-20250311-rc2',
|
||||
resources: {
|
||||
storage: {
|
||||
capacity: 1,
|
||||
__typename: 'ConfigPostgresResourcesStorage',
|
||||
},
|
||||
enablePublicAccess: null,
|
||||
__typename: 'ConfigPostgresResources',
|
||||
},
|
||||
pitr: null,
|
||||
__typename: 'ConfigPostgres',
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
// {"data":}
|
||||
144
dashboard/src/tests/msw/mocks/graphql/getProjectsQuery.ts
Normal file
144
dashboard/src/tests/msw/mocks/graphql/getProjectsQuery.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export const getProjectsQuery = nhostGraphQLLink.query(
|
||||
'getProjects',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
apps: [
|
||||
{
|
||||
id: 'pitr-usa-id',
|
||||
name: 'pitr-not-enabled-usa',
|
||||
slug: 'pitr-not-enabled-usa',
|
||||
createdAt: '2025-03-10T12:35:23.193578+00:00',
|
||||
subdomain: 'ocrnpctsphttfxkuefyx',
|
||||
region: {
|
||||
id: '1',
|
||||
name: 'us-east-1',
|
||||
__typename: 'regions',
|
||||
},
|
||||
deployments: [],
|
||||
creator: {
|
||||
id: 'creator-r-elek-id',
|
||||
email: 'robert@elek.com',
|
||||
displayName: 'Robert',
|
||||
__typename: 'users',
|
||||
},
|
||||
appStates: [
|
||||
{
|
||||
id: 'cd2b77ac-3ef1-4a76-819b-ff1caca09213',
|
||||
appId: 'pitr-usa-id',
|
||||
message:
|
||||
'failed to get dns manager: unknown region: 55985cd4-af14-4d2a-90a5-2a1253ebc1db',
|
||||
stateId: 8,
|
||||
createdAt: '2025-03-10T12:39:23.734345+00:00',
|
||||
__typename: 'appStateHistory',
|
||||
},
|
||||
],
|
||||
__typename: 'apps',
|
||||
},
|
||||
{
|
||||
id: 'pitr-region-TEST-eu-id',
|
||||
name: 'pitr-region-test-eu',
|
||||
slug: 'pitr-region-test-eu',
|
||||
createdAt: '2025-03-10T12:45:40.813234+00:00',
|
||||
subdomain: 'doszbxwibtopsbfgbjpg',
|
||||
region: {
|
||||
id: 'dd6f8e01-35a9-4ba6-8dc6-ed972f2db93c',
|
||||
name: 'eu-central-1',
|
||||
__typename: 'regions',
|
||||
},
|
||||
deployments: [],
|
||||
creator: {
|
||||
id: 'creator-r-elek-id',
|
||||
email: 'robert@elek.com',
|
||||
displayName: 'Robert',
|
||||
__typename: 'users',
|
||||
},
|
||||
appStates: [
|
||||
{
|
||||
id: 'c7fbf7ad-b60c-432b-86c2-5a9509054c47',
|
||||
appId: 'pitr-region-TEST-eu-id',
|
||||
message: '',
|
||||
stateId: 5,
|
||||
createdAt: '2025-03-12T11:08:59.926611+00:00',
|
||||
__typename: 'appStateHistory',
|
||||
},
|
||||
],
|
||||
__typename: 'apps',
|
||||
},
|
||||
{
|
||||
id: 'pitr-test-id',
|
||||
name: 'pitr-test',
|
||||
slug: 'pitr-test',
|
||||
createdAt: '2025-03-04T13:48:59.76498+00:00',
|
||||
subdomain: 'gnlivtcgjxctuujxpslj',
|
||||
region: {
|
||||
id: '1',
|
||||
name: 'us-east-1',
|
||||
__typename: 'regions',
|
||||
},
|
||||
deployments: [],
|
||||
creator: {
|
||||
id: 'creator-d-elek-id',
|
||||
email: 'dbarrosop@dravetech.com',
|
||||
displayName: 'David Elek',
|
||||
__typename: 'users',
|
||||
},
|
||||
appStates: [
|
||||
{
|
||||
id: 'fc344bc6-1c59-447a-813f-e0f65754b0e0',
|
||||
appId: 'pitr-test-id',
|
||||
message:
|
||||
'failed to deploy application to kubernetes: failed to deploy application: failed to check rollout status: error running kubectl: exit status 1',
|
||||
stateId: 8,
|
||||
createdAt: '2025-03-11T15:34:41.25304+00:00',
|
||||
__typename: 'appStateHistory',
|
||||
},
|
||||
],
|
||||
__typename: 'apps',
|
||||
},
|
||||
{
|
||||
id: 'pitr14-id',
|
||||
name: 'pitr14',
|
||||
slug: 'pitr14',
|
||||
createdAt: '2025-02-25T08:55:22.82937+00:00',
|
||||
subdomain: 'jqumebxpocjytrhevonb',
|
||||
region: {
|
||||
id: '1',
|
||||
name: 'us-east-1',
|
||||
__typename: 'regions',
|
||||
},
|
||||
deployments: [],
|
||||
creator: {
|
||||
id: 'creator-d-elek-id',
|
||||
email: 'david@elek.com',
|
||||
displayName: 'David Elek',
|
||||
__typename: 'users',
|
||||
},
|
||||
appStates: [
|
||||
{
|
||||
id: '04bc2db3-a948-48fb-b674-7a8a0133dd2b',
|
||||
appId: 'pitr14-id',
|
||||
message: '',
|
||||
stateId: 5,
|
||||
createdAt: '2025-03-11T20:47:03.102948+00:00',
|
||||
__typename: 'appStateHistory',
|
||||
},
|
||||
],
|
||||
__typename: 'apps',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const getEmptyProjectsQuery = nhostGraphQLLink.query(
|
||||
'getProjects',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
apps: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -23,18 +23,17 @@ declare namespace Intl {
|
||||
// eslint-disable-next-line vars-on-top, no-var
|
||||
var DateTimeFormat: DateTimeFormat;
|
||||
}
|
||||
// Common
|
||||
|
||||
export const UTC_GMT_TIMEZONE = {
|
||||
label: 'UTC, GMT (UTC+00:00)',
|
||||
value: 'UTC',
|
||||
key: 'UTC',
|
||||
};
|
||||
// Common
|
||||
// Common
|
||||
|
||||
export function guessTimezone() {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
// Common
|
||||
|
||||
export function getUTCOffsetInHours(
|
||||
timezone: string,
|
||||
dateTime: string,
|
||||
@@ -43,7 +42,7 @@ export function getUTCOffsetInHours(
|
||||
const date = new TZDate(dateTime, timezone);
|
||||
return format(date, dateFormat).replace('GMT', 'UTC');
|
||||
}
|
||||
// Common
|
||||
|
||||
export function createTimezoneOptions(dateTime: string) {
|
||||
const validTimezones = new Set(Intl.supportedValuesOf('timeZone'));
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
// @ts-ignore
|
||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] }), react()],
|
||||
test: {
|
||||
globalSetup: './vitest.global-setup.ts',
|
||||
testTimeout: 30000,
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
|
||||
3
dashboard/vitest.global-setup.ts
Normal file
3
dashboard/vitest.global-setup.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const setup = () => {
|
||||
process.env.TZ = 'Europe/Helsinki';
|
||||
};
|
||||
@@ -24,7 +24,7 @@
|
||||
"@nhost/react": "workspace:^",
|
||||
"@nhost/react-apollo": "workspace:^",
|
||||
"graphql": "16.8.1",
|
||||
"next": "^14.2.22",
|
||||
"next": "^14.2.25",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-icons": "^4.12.0"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"graphql": "16.8.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "^14.2.22",
|
||||
"next": "^14.2.25",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/adapter-vercel": "^5.6.3",
|
||||
"@sveltejs/kit": "^2.11.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
|
||||
@@ -82,6 +82,8 @@
|
||||
"husky": "^8.0.3",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-organize-imports": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"turbo": "2.3.3",
|
||||
"typedoc": "^0.22.18",
|
||||
"typescript": "4.9.5",
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
"devDependencies": {
|
||||
"@nhost/docgen": "workspace:*",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"next": "^14.2.22",
|
||||
"next": "^14.2.25",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
|
||||
478
pnpm-lock.yaml
generated
478
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"@craco/craco": "^7.1.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"@icons-pack/react-simple-icons": "^9.6.0",
|
||||
"@nhost/react": "^3.10.1",
|
||||
"@nhost/react": "^3.10.2",
|
||||
"@nhost/react-apollo": "^16.0.1",
|
||||
"@radix-ui/react-dialog": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"@nhost-examples/sveltekit#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".svelte-kit/**", ".vercel/**"],
|
||||
"env": ["PUBLIC_NHOST_SUBDOMAIN", "PUBLIC_NHOST_REGION"]
|
||||
"env": ["PUBLIC_NHOST_SUBDOMAIN", "PUBLIC_NHOST_REGION", "ENABLE_EXPERIMENTAL_COREPACK"]
|
||||
},
|
||||
"@nhost-examples/vue-apollo#build": {
|
||||
"dependsOn": ["^build"],
|
||||
|
||||
Reference in New Issue
Block a user