Compare commits
15 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60d4d28627 | ||
|
|
34fdcb8863 | ||
|
|
78436ca29e | ||
|
|
ea6584614b | ||
|
|
4937c5e055 | ||
|
|
b5a3895e16 | ||
|
|
9b24807562 | ||
|
|
15421321f4 | ||
|
|
a4790c6eac | ||
|
|
992aa997d5 | ||
|
|
382dc11aaa | ||
|
|
1976bc48a5 | ||
|
|
38696f5e88 | ||
|
|
064ea6a337 | ||
|
|
0d323e10f5 |
@@ -73,7 +73,7 @@ const nhost = new NhostClient({
|
|||||||
region: '<your-region>'
|
region: '<your-region>'
|
||||||
})
|
})
|
||||||
|
|
||||||
await nhost.auth.signIn({ email: 'elon@musk.com', password: 'spaceX' })
|
await nhost.auth.signIn({ email: 'user@domain.com', password: 'userPassword' })
|
||||||
|
|
||||||
await nhost.graphql.request(`{
|
await nhost.graphql.request(`{
|
||||||
users {
|
users {
|
||||||
|
|||||||
@@ -1,5 +1,20 @@
|
|||||||
# @nhost/dashboard
|
# @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
|
## 2.17.0
|
||||||
|
|
||||||
### Minor Changes
|
### Minor Changes
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
test('should be able to create then delete a personal access token', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
test.beforeAll(async ({ browser }) => {
|
}) => {
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to create then delete a personal access token', async () => {
|
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
await page.getByRole('banner').getByRole('button').last().click();
|
await page.getByRole('banner').getByRole('button').last().click();
|
||||||
await page.getByRole('link', { name: /account settings/i }).click();
|
await page.getByRole('link', { name: /account settings/i }).click();
|
||||||
|
|||||||
@@ -1,17 +1,8 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { navigateToProject } from '@/e2e/utils';
|
import { navigateToProject } from '@/e2e/utils';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
await navigateToProject({
|
await navigateToProject({
|
||||||
page,
|
page,
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||||
@@ -23,11 +14,9 @@ test.beforeEach(async () => {
|
|||||||
await page.waitForURL(AIRoute);
|
await page.waitForURL(AIRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create and delete an Assistant', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
test('should create and delete an Assistant', async () => {
|
|
||||||
await page.getByRole('link', { name: 'Assistants' }).click();
|
await page.getByRole('link', { name: 'Assistants' }).click();
|
||||||
|
|
||||||
await expect(page.getByText(/no assistants are configured/i)).toBeVisible();
|
await expect(page.getByText(/no assistants are configured/i)).toBeVisible();
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { navigateToProject } from '@/e2e/utils';
|
import { navigateToProject } from '@/e2e/utils';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
await navigateToProject({
|
await navigateToProject({
|
||||||
page,
|
page,
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||||
@@ -23,11 +15,9 @@ test.beforeEach(async () => {
|
|||||||
await page.waitForURL(AIRoute);
|
await page.waitForURL(AIRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create and delete an Auto-Embeddings', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
test('should create and delete an Auto-Embeddings', async () => {
|
|
||||||
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
|
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Name').fill('test');
|
await page.getByLabel('Name').fill('test');
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import test, { expect } from '@playwright/test';
|
|
||||||
|
|
||||||
test('should be able to ban and unban a user', async ({ page }) => {
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
await gotoAuthURL(page);
|
||||||
await page.goto(authUrl);
|
});
|
||||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
|
||||||
|
|
||||||
|
test('should be able to ban and unban a user', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,12 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import test, { expect } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await gotoAuthURL(page);
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should create a user', async ({ authenticatedNhostPage: page }) => {
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
|
||||||
await page.goto(authUrl);
|
|
||||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create a user', async () => {
|
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
@@ -31,7 +17,9 @@ test('should create a user', async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not be able to create a user with an existing email', async () => {
|
test('should not be able to create a user with an existing email', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import test, { expect } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
page = await browser.newPage();
|
await gotoAuthURL(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should be able to delete a user', async ({
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
authenticatedNhostPage: page,
|
||||||
await page.goto(authUrl);
|
}) => {
|
||||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to delete a user', async () => {
|
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
@@ -52,7 +41,9 @@ test('should be able to delete a user', async () => {
|
|||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to delete a user from the details page', async () => {
|
test('should be able to delete a user from the details page', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import test, { expect } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await gotoAuthURL(page);
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should be able to edit user roles from the details page', async ({
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
authenticatedNhostPage: page,
|
||||||
await page.goto(authUrl);
|
}) => {
|
||||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to edit user roles from the details page', async () => {
|
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,14 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await gotoAuthURL(page);
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test('should be able to verify the email of a user', async ({
|
||||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
authenticatedNhostPage: page,
|
||||||
await page.goto(authUrl);
|
}) => {
|
||||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.afterAll(async () => {
|
|
||||||
await page.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should be able to verify the email of a user', async () => {
|
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
|
|
||||||
@@ -50,7 +38,9 @@ test('should be able to verify the email of a user', async () => {
|
|||||||
).toBeChecked();
|
).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to verify the phone number of a user', async () => {
|
test('should be able to verify the phone number of a user', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
const email = generateTestEmail();
|
const email = generateTestEmail();
|
||||||
const password = faker.internet.password();
|
const password = faker.internet.password();
|
||||||
const phoneNumber = faker.phone.number();
|
const phoneNumber = faker.phone.number();
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { navigateToProject, prepareTable } from '@/e2e/utils';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
|
import { prepareTable } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { snakeCase } from 'snake-case';
|
import { snakeCase } from 'snake-case';
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await navigateToProject({
|
|
||||||
page,
|
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
|
||||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
|
||||||
});
|
|
||||||
|
|
||||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
await page.goto(databaseRoute);
|
await page.goto(databaseRoute);
|
||||||
await page.waitForURL(databaseRoute);
|
await page.waitForURL(databaseRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create a simple table', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
test('should create a simple table', async () => {
|
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
@@ -57,7 +40,9 @@ test('should create a simple table', async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create a table with unique constraints', async () => {
|
test('should create a table with unique constraints', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
@@ -86,7 +71,9 @@ test('should create a table with unique constraints', async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create a table with nullable columns', async () => {
|
test('should create a table with nullable columns', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
@@ -115,7 +102,9 @@ test('should create a table with nullable columns', async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create a table with an identity column', async () => {
|
test('should create a table with an identity column', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
@@ -148,7 +137,9 @@ test('should create a table with an identity column', async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create table with foreign key constraint', async () => {
|
test('should create table with foreign key constraint', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
@@ -221,7 +212,9 @@ test('should create table with foreign key constraint', async () => {
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not be able to create a table with a name that already exists', async () => {
|
test('should not be able to create a table with a name that already exists', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,17 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { deleteTable, navigateToProject, prepareTable } from '@/e2e/utils';
|
import { deleteTable, prepareTable } from '@/e2e/utils';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { snakeCase } from 'snake-case';
|
import { snakeCase } from 'snake-case';
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await navigateToProject({
|
|
||||||
page,
|
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
|
||||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
|
||||||
});
|
|
||||||
|
|
||||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
await page.goto(databaseRoute);
|
await page.goto(databaseRoute);
|
||||||
await page.waitForURL(databaseRoute);
|
await page.waitForURL(databaseRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should delete a table', async ({ authenticatedNhostPage: page }) => {
|
||||||
await page.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should delete a table', async () => {
|
|
||||||
const tableName = snakeCase(faker.lorem.words(3));
|
const tableName = snakeCase(faker.lorem.words(3));
|
||||||
|
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
@@ -65,7 +47,9 @@ test('should delete a table', async () => {
|
|||||||
).not.toBeVisible();
|
).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
|
test('should not be able to delete a table if other tables have foreign keys referencing it', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
test.setTimeout(60000);
|
test.setTimeout(60000);
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|||||||
@@ -1,39 +1,18 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import {
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
clickPermissionButton,
|
import { clickPermissionButton, prepareTable } from '@/e2e/utils';
|
||||||
navigateToProject,
|
|
||||||
prepareTable,
|
|
||||||
} from '@/e2e/utils';
|
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { snakeCase } from 'snake-case';
|
import { snakeCase } from 'snake-case';
|
||||||
|
|
||||||
let page: Page;
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await navigateToProject({
|
|
||||||
page,
|
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
|
||||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
|
||||||
});
|
|
||||||
|
|
||||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
await page.goto(databaseRoute);
|
await page.goto(databaseRoute);
|
||||||
await page.waitForURL(databaseRoute);
|
await page.waitForURL(databaseRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create a table with role permissions to select row', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
test('should create a table with role permissions to select row', async () => {
|
|
||||||
await page.getByRole('button', { name: /new table/i }).click();
|
await page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
@@ -79,7 +58,9 @@ test('should create a table with role permissions to select row', async () => {
|
|||||||
).toBeVisible();
|
).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 page.getByRole('button', { name: /new table/i }).click();
|
||||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||||
|
|
||||||
|
|||||||
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { TEST_DASHBOARD_URL, TEST_PERSONAL_ORG_SLUG } from '@/e2e/env';
|
||||||
|
import { type Page, test as base } from '@playwright/test';
|
||||||
|
|
||||||
|
export const AUTH_CONTEXT = 'e2e/.auth/user.json';
|
||||||
|
|
||||||
|
export const test = base.extend<{ authenticatedNhostPage: Page }>({
|
||||||
|
authenticatedNhostPage: async ({ browser }, use) => {
|
||||||
|
const context = await browser.newContext({ storageState: AUTH_CONTEXT });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto('/');
|
||||||
|
await page.waitForURL(
|
||||||
|
`${TEST_DASHBOARD_URL}/orgs/${TEST_PERSONAL_ORG_SLUG}/projects`,
|
||||||
|
{ waitUntil: 'networkidle' },
|
||||||
|
);
|
||||||
|
await use(page);
|
||||||
|
// update the context to get the new refresh token
|
||||||
|
await page.context().storageState({ path: AUTH_CONTEXT });
|
||||||
|
await page.close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
@@ -1,15 +1,8 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import type { Page } from '@playwright/test';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { expect, test } from '@playwright/test';
|
import { navigateToProject } from '@/e2e/utils';
|
||||||
import { navigateToProject } from '../utils';
|
|
||||||
|
|
||||||
let page: Page;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
await navigateToProject({
|
await navigateToProject({
|
||||||
page,
|
page,
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||||
@@ -17,11 +10,9 @@ test.beforeAll(async ({ browser }) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should show the navtree with all links visible', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
test('should show the navtree with all links visible', async () => {
|
|
||||||
const navLocator = page.getByLabel('Navigation Tree');
|
const navLocator = page.getByLabel('Navigation Tree');
|
||||||
await expect(navLocator).toBeVisible();
|
await expect(navLocator).toBeVisible();
|
||||||
|
|
||||||
@@ -42,16 +33,20 @@ test('should show the navtree with all links visible', async () => {
|
|||||||
'Settings',
|
'Settings',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax
|
||||||
for (const linkName of links) {
|
for (const linkName of links) {
|
||||||
const link =
|
const link =
|
||||||
linkName === 'Settings'
|
linkName === 'Settings'
|
||||||
? page.getByRole('link', { name: linkName }).first()
|
? page.getByRole('link', { name: linkName }).first()
|
||||||
: page.getByRole('link', { name: linkName });
|
: page.getByRole('link', { name: linkName });
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await expect(link).toBeVisible();
|
await expect(link).toBeVisible();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should show the project's region and subdomain", async () => {
|
test("should show the project's region and subdomain", async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
|
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
|
||||||
/frankfurt \(eu-central-1\)/i,
|
/frankfurt \(eu-central-1\)/i,
|
||||||
);
|
);
|
||||||
@@ -60,7 +55,9 @@ test("should show the project's region and subdomain", async () => {
|
|||||||
).toHaveText(/[a-z]{20}/i);
|
).toHaveText(/[a-z]{20}/i);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not have a GitHub repository connected', async () => {
|
test('should not have a GitHub repository connected', async ({
|
||||||
|
authenticatedNhostPage: page,
|
||||||
|
}) => {
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('button', { name: /connect to github/i }).first(),
|
page.getByRole('button', { name: /connect to github/i }).first(),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
|||||||
@@ -1,33 +1,15 @@
|
|||||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import type { Page } from '@playwright/test';
|
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||||
import { expect, test } from '@playwright/test';
|
|
||||||
import { navigateToProject } from '../utils';
|
|
||||||
|
|
||||||
let page: Page;
|
|
||||||
|
|
||||||
test.beforeAll(async ({ browser }) => {
|
|
||||||
page = await browser.newPage();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
|
||||||
await page.goto('/');
|
|
||||||
|
|
||||||
await navigateToProject({
|
|
||||||
page,
|
|
||||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
|
||||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`;
|
const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`;
|
||||||
await page.goto(runRoute);
|
await page.goto(runRoute);
|
||||||
await page.waitForURL(runRoute);
|
await page.waitForURL(runRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test('should create and delete a run service', async ({
|
||||||
await page.close();
|
authenticatedNhostPage: page,
|
||||||
});
|
}) => {
|
||||||
|
|
||||||
test('should create and delete a run service', async () => {
|
|
||||||
await page.getByRole('button', { name: 'Add service' }).first().click();
|
await page.getByRole('button', { name: 'Add service' }).first().click();
|
||||||
await expect(page.getByText(/create a new service/i)).toBeVisible();
|
await expect(page.getByText(/create a new service/i)).toBeVisible();
|
||||||
await page.getByPlaceholder(/service name/i).click();
|
await page.getByPlaceholder(/service name/i).click();
|
||||||
|
|||||||
@@ -1,49 +1,23 @@
|
|||||||
import {
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
TEST_DASHBOARD_URL,
|
import { expect, test as teardown } from '@/e2e/fixtures/auth-hook';
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
teardown.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||||
await page.goto(databaseRoute);
|
await page.goto(databaseRoute);
|
||||||
await page.waitForURL(databaseRoute);
|
await page.waitForURL(databaseRoute);
|
||||||
});
|
});
|
||||||
|
|
||||||
teardown.afterAll(async () => {
|
teardown(
|
||||||
await page.close();
|
'clean up database tables',
|
||||||
});
|
async ({ authenticatedNhostPage: page }) => {
|
||||||
|
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||||
|
|
||||||
teardown('clean up database tables', async () => {
|
await page.waitForURL(
|
||||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||||
|
);
|
||||||
|
|
||||||
await page.waitForURL(
|
const inputField = page.locator('[contenteditable]');
|
||||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
await inputField.fill(`
|
||||||
);
|
|
||||||
|
|
||||||
const inputField = page.locator('[contenteditable]');
|
|
||||||
await inputField.fill(`
|
|
||||||
DO $$ DECLARE
|
DO $$ DECLARE
|
||||||
tablename text;
|
tablename text;
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -56,6 +30,7 @@ teardown('clean up database tables', async () => {
|
|||||||
END $$;
|
END $$;
|
||||||
`);
|
`);
|
||||||
|
|
||||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||||
await expect(page.getByText(/success/i)).toBeVisible();
|
await expect(page.getByText(/success/i)).toBeVisible();
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||||
import { faker } from '@faker-js/faker';
|
import { faker } from '@faker-js/faker';
|
||||||
import type { Page } from '@playwright/test';
|
import type { Page } from '@playwright/test';
|
||||||
|
|
||||||
@@ -211,3 +212,9 @@ export async function clickPermissionButton({
|
|||||||
.locator('button')
|
.locator('button')
|
||||||
.click();
|
.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' });
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const { version } = require('./package.json');
|
|||||||
const cspHeader = `
|
const cspHeader = `
|
||||||
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
default-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run;
|
||||||
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
script-src 'self' 'unsafe-eval' 'unsafe-inline' cdn.segment.com js.stripe.com;
|
||||||
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com;
|
connect-src 'self' *.nhost.run ws://*.nhost.run nhost.run ws://nhost.run discord.com api.segment.io api.segment.com cdn.segment.com nhost.zendesk.com;
|
||||||
style-src 'self' 'unsafe-inline';
|
style-src 'self' 'unsafe-inline';
|
||||||
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
img-src 'self' blob: data: avatars.githubusercontent.com s.gravatar.com *.nhost.run nhost.run;
|
||||||
font-src 'self' data:;
|
font-src 'self' data:;
|
||||||
@@ -38,10 +38,10 @@ module.exports = withBundleAnalyzer({
|
|||||||
{
|
{
|
||||||
source: '/(.*)',
|
source: '/(.*)',
|
||||||
headers: [
|
headers: [
|
||||||
{
|
// {
|
||||||
key: 'Content-Security-Policy',
|
// key: 'Content-Security-Policy',
|
||||||
value: cspHeader.replace(/\s+/g, ' ').trim(),
|
// hgvalue: cspHeader.replace(/\s+/g, ' ').trim(),
|
||||||
},
|
// },
|
||||||
{
|
{
|
||||||
key: 'X-Frame-Options',
|
key: 'X-Frame-Options',
|
||||||
value: 'DENY',
|
value: 'DENY',
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@nhost/dashboard",
|
"name": "@nhost/dashboard",
|
||||||
"version": "2.22.0",
|
"version": "2.25.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
@@ -87,7 +87,7 @@
|
|||||||
"just-kebab-case": "^4.2.0",
|
"just-kebab-case": "^4.2.0",
|
||||||
"lodash.debounce": "^4.0.8",
|
"lodash.debounce": "^4.0.8",
|
||||||
"lucide-react": "^0.416.0",
|
"lucide-react": "^0.416.0",
|
||||||
"next": "^14.2.22",
|
"next": "^14.2.25",
|
||||||
"next-nprogress-bar": "^2.3.13",
|
"next-nprogress-bar": "^2.3.13",
|
||||||
"next-seo": "^6.5.0",
|
"next-seo": "^6.5.0",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-children-utilities": "^2.10.0",
|
"react-children-utilities": "^2.10.0",
|
||||||
"react-complex-tree": "^2.4.5",
|
"react-complex-tree": "^2.4.5",
|
||||||
"react-day-picker": "8.10.1",
|
"react-day-picker": "9.6.3",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "^4.0.13",
|
"react-error-boundary": "^4.0.13",
|
||||||
"react-hook-form": "^7.53.0",
|
"react-hook-form": "^7.53.0",
|
||||||
|
|||||||
@@ -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';
|
align?: 'start' | 'center' | 'end';
|
||||||
validateDateFn?: (date: Date) => string;
|
validateDateFn?: (date: Date) => string;
|
||||||
}
|
}
|
||||||
// in: UTC datetime
|
|
||||||
// out: UTC dateTime
|
|
||||||
|
|
||||||
function DateTimePicker({
|
function DateTimePicker({
|
||||||
dateTime,
|
dateTime,
|
||||||
@@ -49,6 +47,10 @@ function DateTimePicker({
|
|||||||
});
|
});
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const [timezone, setTimezone] = useState(
|
||||||
|
() => defaultTimezone || guessTimezone(),
|
||||||
|
);
|
||||||
|
|
||||||
function emitNewDateTime() {
|
function emitNewDateTime() {
|
||||||
onDateTimeChange(new Date(date.getTime()).toISOString());
|
onDateTimeChange(new Date(date.getTime()).toISOString());
|
||||||
}
|
}
|
||||||
@@ -73,6 +75,7 @@ function DateTimePicker({
|
|||||||
|
|
||||||
function handleTimezoneChange(newTimezone: string) {
|
function handleTimezoneChange(newTimezone: string) {
|
||||||
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
|
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
|
||||||
|
setTimezone(newTimezone);
|
||||||
setDate(newDateWithTimezone);
|
setDate(newDateWithTimezone);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +83,7 @@ function DateTimePicker({
|
|||||||
if (!newOpenState) {
|
if (!newOpenState) {
|
||||||
if (withTimezone) {
|
if (withTimezone) {
|
||||||
const tz = defaultTimezone || guessTimezone();
|
const tz = defaultTimezone || guessTimezone();
|
||||||
|
setTimezone(tz);
|
||||||
setDate(new TZDate(dateTime, tz));
|
setDate(new TZDate(dateTime, tz));
|
||||||
}
|
}
|
||||||
setDate(parseISO(dateTime));
|
setDate(parseISO(dateTime));
|
||||||
@@ -92,6 +96,8 @@ function DateTimePicker({
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedDateInUTC = new Date(date.getTime()).toISOString();
|
||||||
|
|
||||||
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
|
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
|
||||||
|
|
||||||
const errorText = validateDateFn?.(date);
|
const errorText = validateDateFn?.(date);
|
||||||
@@ -101,6 +107,7 @@ function DateTimePicker({
|
|||||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="dateTimePickerTrigger"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-between text-left font-normal',
|
'w-full justify-between text-left font-normal',
|
||||||
@@ -113,6 +120,7 @@ function DateTimePicker({
|
|||||||
<CalendarIcon className="h-4 w-4" />
|
<CalendarIcon className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent className="w-auto p-0" align={align}>
|
<PopoverContent className="w-auto p-0" align={align}>
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="flex">
|
<div className="flex">
|
||||||
@@ -120,8 +128,8 @@ function DateTimePicker({
|
|||||||
mode="single"
|
mode="single"
|
||||||
selected={date}
|
selected={date}
|
||||||
onSelect={(d) => handleSelect(d)}
|
onSelect={(d) => handleSelect(d)}
|
||||||
initialFocus
|
|
||||||
disabled={isCalendarDayDisabled}
|
disabled={isCalendarDayDisabled}
|
||||||
|
timeZone={timezone}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-col justify-between">
|
<div className="flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -131,7 +139,7 @@ function DateTimePicker({
|
|||||||
{withTimezone && (
|
{withTimezone && (
|
||||||
<div className="border-t border-border p-3">
|
<div className="border-t border-border p-3">
|
||||||
<TimezoneSettings
|
<TimezoneSettings
|
||||||
dateTime={dateTime}
|
dateTime={selectedDateInUTC}
|
||||||
onTimezoneChange={handleTimezoneChange}
|
onTimezoneChange={handleTimezoneChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,16 +18,23 @@ function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
|
|||||||
setTimezone(tz.value);
|
setTimezone(tz.value);
|
||||||
onTimezoneChange?.(tz.value);
|
onTimezoneChange?.(tz.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
|
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full items-center justify-between">
|
<div className="flex w-full items-center justify-between">
|
||||||
Timezone: {utcOffset}{' '}
|
<span>Timezone: {utcOffset}</span>
|
||||||
<TimezonePicker
|
<TimezonePicker
|
||||||
dateTime={dateTime}
|
dateTime={dateTime}
|
||||||
selectedTimezone={selectedTimezone}
|
selectedTimezone={selectedTimezone}
|
||||||
onTimezoneSelect={handleTimezoneSelect}
|
onTimezoneSelect={handleTimezoneSelect}
|
||||||
button={
|
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" />
|
<Settings2 className="h-4 w-4 dark:text-foreground" />
|
||||||
</Button>
|
</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;
|
return date instanceof TZDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,26 @@ interface Props {
|
|||||||
dateTime: string;
|
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({
|
function TimezonePicker({
|
||||||
selectedTimezone,
|
selectedTimezone,
|
||||||
onTimezoneSelect,
|
onTimezoneSelect,
|
||||||
@@ -16,9 +36,10 @@ function TimezonePicker({
|
|||||||
dateTime,
|
dateTime,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const timezoneOptions = useMemo(
|
const timezoneOptions = useMemo(
|
||||||
() => createTimezoneOptions(dateTime),
|
() => getOrderedTimezones(dateTime, selectedTimezone),
|
||||||
[dateTime],
|
[dateTime, selectedTimezone],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualizedCombobox
|
<VirtualizedCombobox
|
||||||
options={timezoneOptions}
|
options={timezoneOptions}
|
||||||
@@ -27,6 +48,7 @@ function TimezonePicker({
|
|||||||
searchPlaceholder="Search timezones..."
|
searchPlaceholder="Search timezones..."
|
||||||
button={button}
|
button={button}
|
||||||
side="right"
|
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 (
|
return (
|
||||||
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
<Command
|
||||||
|
shouldFilter={false}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
value={selectedOption}
|
||||||
|
>
|
||||||
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
||||||
<CommandList
|
<CommandList
|
||||||
ref={parentRef}
|
ref={parentRef}
|
||||||
@@ -145,7 +137,6 @@ function VirtualizedCommand<O extends Option>({
|
|||||||
filteredOptions[virtualOption.index].key ??
|
filteredOptions[virtualOption.index].key ??
|
||||||
filteredOptions[virtualOption.index].value
|
filteredOptions[virtualOption.index].value
|
||||||
}
|
}
|
||||||
disabled={isKeyboardNavActive}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute left-0 top-0 w-full bg-transparent',
|
'absolute left-0 top-0 w-full bg-transparent',
|
||||||
focusedIndex === virtualOption.index &&
|
focusedIndex === virtualOption.index &&
|
||||||
|
|||||||
@@ -1,75 +1,174 @@
|
|||||||
'use client';
|
/* eslint-disable react/no-unstable-nested-components */
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
'use client';
|
||||||
import {
|
|
||||||
DayPicker,
|
|
||||||
type DayPickerProps,
|
|
||||||
type StyledComponent,
|
|
||||||
} from 'react-day-picker';
|
|
||||||
|
|
||||||
import { buttonVariants } from '@/components/ui/v3/button';
|
import { buttonVariants } from '@/components/ui/v3/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { DayPicker, type DayPickerProps } from 'react-day-picker';
|
||||||
|
|
||||||
const IconLeft = ({ className, ...props }: StyledComponent) => (
|
export type CalendarProps = DayPickerProps & {
|
||||||
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
|
/**
|
||||||
);
|
* In the year view, the number of years to display at once.
|
||||||
const IconRight = ({ className, ...props }: StyledComponent) => (
|
* @default 12
|
||||||
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
|
*/
|
||||||
);
|
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({
|
function Calendar({
|
||||||
className,
|
className,
|
||||||
classNames,
|
|
||||||
showOutsideDays = true,
|
showOutsideDays = true,
|
||||||
|
numberOfMonths,
|
||||||
...props
|
...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 (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
className={cn('p-3', className)}
|
className={cn('p-3', className)}
|
||||||
classNames={{
|
classNames={{
|
||||||
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
|
months: monthsClassName,
|
||||||
month: 'space-y-4',
|
month_caption: monthCaptionClassName,
|
||||||
caption: 'flex justify-center pt-1 relative items-center',
|
weekdays: weekdaysClassName,
|
||||||
caption_label: 'text-sm font-medium',
|
weekday: weekdayClassName,
|
||||||
nav: 'space-x-1 flex items-center',
|
month: monthClassName,
|
||||||
nav_button: cn(
|
caption: captionClassName,
|
||||||
buttonVariants({ variant: 'outline' }),
|
caption_label: captionLabelClassName,
|
||||||
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
|
button_next: buttonNextClassName,
|
||||||
),
|
button_previous: buttonPreviousClassName,
|
||||||
nav_button_previous: 'absolute left-1',
|
nav: navClassName,
|
||||||
nav_button_next: 'absolute right-1',
|
month_grid: monthGridClassName,
|
||||||
table: 'w-full border-collapse space-y-1',
|
week: weekClassName,
|
||||||
head_row: 'flex',
|
day: dayClassName,
|
||||||
head_cell:
|
day_button: dayButtonClassName,
|
||||||
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
|
range_start: rangeStartClassName,
|
||||||
row: 'flex w-full mt-2',
|
range_middle: rangeMiddleClassName,
|
||||||
cell: cn(
|
range_end: rangeEndClassName,
|
||||||
'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',
|
selected: selectedClassName,
|
||||||
props.mode === 'range'
|
today: todayClassName,
|
||||||
? '[&: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'
|
outside: outsideClassName,
|
||||||
: '[&:has([aria-selected])]:rounded-md',
|
disabled: disabledClassName,
|
||||||
),
|
hidden: hiddenClassName,
|
||||||
day: cn(
|
|
||||||
buttonVariants({ variant: 'ghost' }),
|
|
||||||
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
|
|
||||||
),
|
|
||||||
day_range_start: 'day-range-start',
|
|
||||||
day_range_end: 'day-range-end',
|
|
||||||
day_selected:
|
|
||||||
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
|
|
||||||
day_today: 'bg-accent text-accent-foreground',
|
|
||||||
day_outside:
|
|
||||||
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
|
|
||||||
day_disabled: 'text-muted-foreground opacity-50',
|
|
||||||
day_range_middle:
|
|
||||||
'aria-selected:bg-accent aria-selected:text-accent-foreground',
|
|
||||||
day_hidden: 'invisible',
|
|
||||||
...classNames,
|
|
||||||
}}
|
}}
|
||||||
components={{
|
components={{
|
||||||
IconLeft,
|
Chevron: ({ orientation }) => {
|
||||||
IconRight,
|
const Icon = orientation === 'left' ? ChevronLeft : ChevronRight;
|
||||||
|
return <Icon className="h-4 w-4" />;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,16 +35,23 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface CommandInputProps {
|
||||||
|
prefix?: React.ReactNode;
|
||||||
|
prefixClassName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CommandInput = React.forwardRef<
|
const CommandInput = React.forwardRef<
|
||||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> &
|
||||||
prefix?: React.ReactNode;
|
CommandInputProps
|
||||||
}
|
>(({ className, prefix, prefixClassName, ...props }, ref) => (
|
||||||
>(({ className, prefix, ...props }, ref) => (
|
|
||||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
{prefix && (
|
{prefix && (
|
||||||
<span className="pointer-events-none flex items-center text-muted-foreground">
|
<span
|
||||||
|
title={prefix}
|
||||||
|
className={cn('text-muted-foreground', prefixClassName)}
|
||||||
|
>
|
||||||
{prefix}
|
{prefix}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,24 +5,33 @@ import { type PropsWithChildren, type ReactNode } from 'react';
|
|||||||
interface Props {
|
interface Props {
|
||||||
title?: string;
|
title?: string;
|
||||||
icon?: ReactNode;
|
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', {
|
const alertClassNames = cn('bg-[#ebf3ff] dark:bg-muted', {
|
||||||
'flex gap-2 items-center': !!icon,
|
'flex gap-2 items-center': !!icon,
|
||||||
|
'border-none': borderLess,
|
||||||
});
|
});
|
||||||
|
|
||||||
const descClassNames = cn('text-[0.9375rem] leading-[22px]', {
|
const descClassNames = cn('text-[0.9375rem] leading-6', {
|
||||||
'text-[0.875rem] leading-[1rem]': !!icon,
|
'text-[0.875rem] leading-6': !!icon,
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<Alert className={alertClassNames}>
|
<Alert className={alertClassNames}>
|
||||||
{icon && <div>{icon}</div>}
|
{icon && <div>{icon}</div>}
|
||||||
<div>
|
<div>
|
||||||
{title && <AlertTitle>{title}</AlertTitle>}
|
{title && <AlertTitle>{title}</AlertTitle>}
|
||||||
<AlertDescription className={descClassNames}>
|
{children && (
|
||||||
{children}
|
<AlertDescription className={descClassNames}>
|
||||||
</AlertDescription>
|
{children}
|
||||||
|
</AlertDescription>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Alert>
|
</Alert>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const getUseRouterObject = (session_id?: string) => ({
|
|||||||
},
|
},
|
||||||
isFallback: false,
|
isFallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
useRouter: vi.fn(),
|
useRouter: vi.fn(),
|
||||||
useOrgs: vi.fn(),
|
useOrgs: vi.fn(),
|
||||||
|
|||||||
@@ -27,8 +27,10 @@ function ProjectCard({ project }: { project: Project }) {
|
|||||||
>
|
>
|
||||||
<div className="flex flex-row items-start gap-2">
|
<div className="flex flex-row items-start gap-2">
|
||||||
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
|
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col overflow-hidden">
|
||||||
<p className="truncate font-bold">{project.name}</p>
|
<p title={project.name} className="truncate font-bold">
|
||||||
|
{project.name}
|
||||||
|
</p>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{project.region.name}
|
{project.region.name}
|
||||||
</span>
|
</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(() =>
|
const [tab, setTab] = useState(() =>
|
||||||
isPiTREnabled ? 'pointInTime' : 'scheduledBackups',
|
isPiTREnabled ? 'pointInTime' : 'scheduledBackups',
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tabs value={tab} onValueChange={setTab}>
|
<Tabs value={tab} onValueChange={setTab}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="scheduledBackups">Scheduled backups</TabsTrigger>
|
<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>
|
<TabsTrigger value="importBackup">Import backup</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<div className="pt-7">
|
<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() {
|
function PiTRNotEnabledOnSourceProject() {
|
||||||
return (
|
return (
|
||||||
<InfoAlert
|
<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]" />}
|
icon={<DatabaseZap className="h-[38px] w-[38px]" />}
|
||||||
>
|
>
|
||||||
Importing from scheduled backups is not supported yet. Coming soon!
|
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 { PointInTimeBackupInfo } from '@/features/orgs/projects/backups/components/common/PointInTimeBackupInfo';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||||
import RecoveryRetentionPeriod from './RecoveryRetentionPeriod';
|
import RecoveryRetentionPeriod from './RecoveryRetentionPeriod';
|
||||||
|
import RestoreRecommendationNote from './RestoreRecommendationNote';
|
||||||
|
|
||||||
function PointInTimeRecovery() {
|
function PointInTimeRecovery() {
|
||||||
const { project } = useProject();
|
const { project } = useProject();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-[1.875rem]">
|
<div className="flex flex-col gap-[1.875rem]">
|
||||||
<RecoveryRetentionPeriod />
|
<RecoveryRetentionPeriod />
|
||||||
<PointInTimeBackupInfo appId={project?.id} />
|
<RestoreRecommendationNote />
|
||||||
|
<PointInTimeBackupInfo appId={project?.id} showLink />
|
||||||
</div>
|
</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() {
|
function PiTREnabledInfoBanner() {
|
||||||
return (
|
return (
|
||||||
<InfoAlert>
|
<InfoAlert>
|
||||||
With PiTR enabled, Scheduled backups are no longer taken. PiTR provides
|
With Point-in-Time Recovery enabled, Scheduled backups are no longer
|
||||||
more precise recovery, making additional backups unnecessary.
|
taken. Point-in-Time Recovery provides more precise recovery, making
|
||||||
|
additional backups unnecessary.
|
||||||
</InfoAlert>
|
</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 { Button } from '@/components/ui/v3/button';
|
||||||
import { DialogFooter } from '@/components/ui/v3/dialog';
|
import { DialogFooter } from '@/components/ui/v3/dialog';
|
||||||
import Link from 'next/link';
|
import TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
|
||||||
import type { PropsWithChildren } from 'react';
|
|
||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
function LogsLink({ href, children }: PropsWithChildren<{ href: string }>) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className="text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
orgSlug: string;
|
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>Your backup has been scheduled successfully and will start shortly.</p>
|
||||||
<p>
|
<p>
|
||||||
To follow its process go to the{' '}
|
To follow its process go to the{' '}
|
||||||
<LogsLink href={`/orgs/${orgSlug}/projects/${subdomain}/logs`}>
|
<TextLink href={`/orgs/${orgSlug}/projects/${subdomain}/logs`}>
|
||||||
Logs page
|
Logs page
|
||||||
</LogsLink>{' '}
|
</TextLink>{' '}
|
||||||
and select the service "Backup Job" to see the restore logs.
|
and select the service "Backup Jobs" to see the restore logs.
|
||||||
</p>
|
</p>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" onClick={onClose}>
|
<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 { render, screen, waitFor } from '@/tests/orgs/testUtils';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { setupServer } from 'msw/node';
|
import { setupServer } from 'msw/node';
|
||||||
import { vi } from 'vitest';
|
import { test, vi } from 'vitest';
|
||||||
|
|
||||||
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||||
@@ -64,37 +64,62 @@ describe('PointInTimeBackupInfo', () => {
|
|||||||
server.listen();
|
server.listen();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterEach(() => {
|
||||||
server.close();
|
server.resetHandlers();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('will fetch the earliest backup and will display the date in with timezone', async () => {
|
afterAll(() => {
|
||||||
server.use(getOrganization);
|
server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("will display the earliest backup's date with the local timezone", async () => {
|
||||||
server.use(getProjectQuery);
|
server.use(getProjectQuery);
|
||||||
|
server.use(getOrganization);
|
||||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||||
fetchPiTRBaseBackups,
|
fetchPiTRBaseBackups,
|
||||||
{ loading: false },
|
{ loading: false },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
await waitFor(() =>
|
||||||
// '10 March 2025, 05:00:05 (UTC+02:00)'
|
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
|
||||||
|
);
|
||||||
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
|
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
|
||||||
expect(earliestBackup).toHaveTextContent(
|
expect(earliestBackup).toHaveTextContent(
|
||||||
'10 Mar 2025, 05:00:05 (UTC+02:00)',
|
'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(getOrganization);
|
||||||
server.use(getProjectQuery);
|
|
||||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||||
fetchPiTRBaseBackups,
|
fetchPiTRBaseBackups,
|
||||||
{ loading: false },
|
{ loading: false },
|
||||||
]);
|
]);
|
||||||
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
// '10 March 2025, 05:00:05 (UTC+02:00)'
|
|
||||||
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
|
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
|
||||||
expect(earliestBackup).toHaveTextContent(
|
expect(earliestBackup).toHaveTextContent(
|
||||||
'10 Mar 2025, 05:00:05 (UTC+02:00)',
|
'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 () => {
|
test('will schedule a restore', async () => {
|
||||||
server.use(getOrganization);
|
server.use(getOrganization);
|
||||||
server.use(getProjectQuery);
|
|
||||||
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
|
||||||
fetchPiTRBaseBackups,
|
fetchPiTRBaseBackups,
|
||||||
{ loading: false },
|
{ loading: false },
|
||||||
@@ -167,11 +171,8 @@ describe('PointInTimeBackupInfo', () => {
|
|||||||
await screen.getByRole('button', { name: 'Select' }),
|
await screen.getByRole('button', { name: 'Select' }),
|
||||||
).toBeInTheDocument(),
|
).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
await user.click(
|
|
||||||
await screen.getByRole('gridcell', {
|
await user.click(await screen.getByText('13'));
|
||||||
name: /13/i,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const hoursInput = await screen.getByLabelText('Hours');
|
const hoursInput = await screen.getByLabelText('Hours');
|
||||||
await user.type(hoursInput, '18');
|
await user.type(hoursInput, '18');
|
||||||
@@ -236,5 +237,121 @@ describe('PointInTimeBackupInfo', () => {
|
|||||||
expect(
|
expect(
|
||||||
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
|
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
|
||||||
).toBe('2025-03-13T16:00:05.000Z');
|
).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 usePiTRBaseBackups from '@/features/orgs/hooks/usePiTRBaseBackups/usePiTRBaseBackups';
|
||||||
import { isEmptyValue } from '@/lib/utils';
|
import { cn, isEmptyValue } from '@/lib/utils';
|
||||||
import { Info } from 'lucide-react';
|
import { Info, SquareArrowUpRightIcon } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
import EarliestBackup from './EarliestBackup';
|
import EarliestBackup from './EarliestBackup';
|
||||||
import RestoreBackupDialogButton from './RestoreBackupDialogButton';
|
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 {
|
interface Props {
|
||||||
appId: string;
|
appId: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
dialogTitle?: string;
|
dialogTitle?: string;
|
||||||
dialogButtonText?: string;
|
dialogButtonText?: string;
|
||||||
dialogTriggerText?: string;
|
dialogTriggerText?: string;
|
||||||
|
showLink?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PointInTimeBackupInfo({
|
function PointInTimeBackupInfo({
|
||||||
@@ -18,12 +34,13 @@ function PointInTimeBackupInfo({
|
|||||||
dialogTitle = 'Recover your database from a backup',
|
dialogTitle = 'Recover your database from a backup',
|
||||||
dialogButtonText,
|
dialogButtonText,
|
||||||
dialogTriggerText,
|
dialogTriggerText,
|
||||||
|
showLink = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { earliestBackupDate, loading } = usePiTRBaseBackups(appId);
|
const { earliestBackupDate, loading } = usePiTRBaseBackups(appId);
|
||||||
|
|
||||||
const disableStartRestoreButton = loading || isEmptyValue(earliestBackupDate);
|
const disableStartRestoreButton = loading || isEmptyValue(earliestBackupDate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
/* Move this part to a different component */
|
|
||||||
<div className="rounded-lg border border-[#EAEDF0] dark:border-[#2F363D]">
|
<div className="rounded-lg border border-[#EAEDF0] dark:border-[#2F363D]">
|
||||||
<div className="flex w-full flex-col items-start gap-6 p-4">
|
<div className="flex w-full flex-col items-start gap-6 p-4">
|
||||||
<h3 className="leading-[1.375] text-[0.9375]">
|
<h3 className="leading-[1.375] text-[0.9375]">
|
||||||
@@ -46,7 +63,13 @@ function PointInTimeBackupInfo({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<RestoreBackupDialogButton
|
||||||
disabled={disableStartRestoreButton}
|
disabled={disableStartRestoreButton}
|
||||||
earliestBackupDate={earliestBackupDate}
|
earliestBackupDate={earliestBackupDate}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DateTimePicker } from '@/components/common/DateTimePicker';
|
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 { ButtonWithLoading as Button } from '@/components/ui/v3/button';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
import { useRestoreApplicationDatabasePiTR } from '@/features/orgs/hooks/useRestoreApplicationDatabasePiTR';
|
import { useRestoreApplicationDatabasePiTR } from '@/features/orgs/hooks/useRestoreApplicationDatabasePiTR';
|
||||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
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 { DialogDescription } from '@radix-ui/react-dialog';
|
||||||
import { format, isBefore, startOfDay } from 'date-fns-v4';
|
import { format, isBefore, startOfDay } from 'date-fns-v4';
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
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');
|
return format(date, 'dd MMM yyyy, HH:mm:ss (OOOO)').replace('GMT', 'UTC');
|
||||||
}
|
}
|
||||||
|
|
||||||
function isCalendarDayDisabled(date: Date) {
|
function isCalendarDayDisabled(date: Date | TZDate) {
|
||||||
return isBefore(startOfDay(date), startOfDay(earliestBackupDate));
|
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(() => {
|
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 (
|
return (
|
||||||
<Popover open={open} onOpenChange={setOpen}>
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger
|
||||||
|
asChild
|
||||||
|
title={
|
||||||
|
buttonPrefix
|
||||||
|
? `${buttonPrefix}.${selectedColumn?.label}`
|
||||||
|
: selectedColumn?.label || 'Select a column'
|
||||||
|
}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
ref={ref}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
className="justify-between"
|
className="w-full justify-between"
|
||||||
>
|
>
|
||||||
{buttonPrefix ? (
|
{buttonPrefix ? (
|
||||||
<div className="flex flex-shrink-0 gap-0 truncate">
|
<div className="flex min-w-0 flex-shrink items-center gap-0">
|
||||||
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
<span className="flex-shrink truncate text-sm text-muted-foreground lg:max-w-[200px]">
|
||||||
{buttonPrefix}.
|
{buttonPrefix}.
|
||||||
</span>
|
</span>
|
||||||
{selectedColumn?.label}
|
<span className="truncate">{selectedColumn?.label}</span>
|
||||||
</div>
|
</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" />
|
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -244,10 +253,11 @@ function ColumnAutocomplete(
|
|||||||
placeholder=""
|
placeholder=""
|
||||||
prefix={
|
prefix={
|
||||||
relationshipDotNotation
|
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 ? (
|
{pages?.length > 0 ? (
|
||||||
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
|
<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">
|
<div className="flex gap-3">
|
||||||
<span className="line-clamp-2 break-all">
|
<span
|
||||||
|
title={option.label}
|
||||||
|
className="line-clamp-2 break-all"
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
@@ -330,7 +343,10 @@ function ColumnAutocomplete(
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<span className="line-clamp-2 break-all">
|
<span
|
||||||
|
title={option.label}
|
||||||
|
className="line-clamp-2 break-all"
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||||
|
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||||
import { useDatabasePiTRSettings } from '@/features/orgs/hooks/useDatabasePiTRSettings/';
|
import { useDatabasePiTRSettings } from '@/features/orgs/hooks/useDatabasePiTRSettings/';
|
||||||
import { useUpdateDatabasePiTRConfig } from '@/features/orgs/hooks/useUpdateDatabasePiTRConfig';
|
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 { UpgradeNotification } from '@/features/orgs/projects/database/settings/components/UpgradeNotification';
|
||||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||||
import { isEmptyValue } from '@/lib/utils';
|
import { isEmptyValue } from '@/lib/utils';
|
||||||
@@ -29,8 +31,8 @@ export default function DatabasePiTRSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContainer
|
<SettingsContainer
|
||||||
title="Point-in-time recovery"
|
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."
|
description="Enable Point-in-Time Recovery (PiTR)."
|
||||||
slotProps={{
|
slotProps={{
|
||||||
submitButton: {
|
submitButton: {
|
||||||
disabled: isSwitchDisabled,
|
disabled: isSwitchDisabled,
|
||||||
@@ -43,11 +45,19 @@ export default function DatabasePiTRSettings() {
|
|||||||
showSwitch={shouldShowSwitch}
|
showSwitch={shouldShowSwitch}
|
||||||
enabled={isPiTREnabled}
|
enabled={isPiTREnabled}
|
||||||
onEnabledChange={handleEnabledChange}
|
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"
|
docsTitle="enabling or disabling PiTR"
|
||||||
>
|
>
|
||||||
{isFreeProject && (
|
{isFreeProject ? (
|
||||||
<UpgradeNotification description="To unlock this add-on, transfer this project to a Pro or Team organization." />
|
<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>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export const mockApplication: Project = {
|
|||||||
name: 'Test Application',
|
name: 'Test Application',
|
||||||
slug: 'test-application',
|
slug: 'test-application',
|
||||||
appStates: [],
|
appStates: [],
|
||||||
subdomain: '',
|
subdomain: 'subdomain',
|
||||||
region: {
|
region: {
|
||||||
name: 'us-east-1',
|
name: 'us-east-1',
|
||||||
city: 'New York',
|
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
|
// eslint-disable-next-line vars-on-top, no-var
|
||||||
var DateTimeFormat: DateTimeFormat;
|
var DateTimeFormat: DateTimeFormat;
|
||||||
}
|
}
|
||||||
// Common
|
|
||||||
export const UTC_GMT_TIMEZONE = {
|
export const UTC_GMT_TIMEZONE = {
|
||||||
label: 'UTC, GMT (UTC+00:00)',
|
label: 'UTC, GMT (UTC+00:00)',
|
||||||
value: 'UTC',
|
value: 'UTC',
|
||||||
key: 'UTC',
|
key: 'UTC',
|
||||||
};
|
};
|
||||||
// Common
|
|
||||||
// Common
|
|
||||||
export function guessTimezone() {
|
export function guessTimezone() {
|
||||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||||
}
|
}
|
||||||
// Common
|
|
||||||
export function getUTCOffsetInHours(
|
export function getUTCOffsetInHours(
|
||||||
timezone: string,
|
timezone: string,
|
||||||
dateTime: string,
|
dateTime: string,
|
||||||
@@ -43,7 +42,7 @@ export function getUTCOffsetInHours(
|
|||||||
const date = new TZDate(dateTime, timezone);
|
const date = new TZDate(dateTime, timezone);
|
||||||
return format(date, dateFormat).replace('GMT', 'UTC');
|
return format(date, dateFormat).replace('GMT', 'UTC');
|
||||||
}
|
}
|
||||||
// Common
|
|
||||||
export function createTimezoneOptions(dateTime: string) {
|
export function createTimezoneOptions(dateTime: string) {
|
||||||
const validTimezones = new Set(Intl.supportedValuesOf('timeZone'));
|
const validTimezones = new Set(Intl.supportedValuesOf('timeZone'));
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] }), react()],
|
plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] }), react()],
|
||||||
test: {
|
test: {
|
||||||
|
globalSetup: './vitest.global-setup.ts',
|
||||||
testTimeout: 30000,
|
testTimeout: 30000,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
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": "workspace:^",
|
||||||
"@nhost/react-apollo": "workspace:^",
|
"@nhost/react-apollo": "workspace:^",
|
||||||
"graphql": "16.8.1",
|
"graphql": "16.8.1",
|
||||||
"next": "^14.2.22",
|
"next": "^14.2.25",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-icons": "^4.12.0"
|
"react-icons": "^4.12.0"
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
"form-data": "^4.0.0",
|
"form-data": "^4.0.0",
|
||||||
"graphql": "16.8.1",
|
"graphql": "16.8.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^14.2.22",
|
"next": "^14.2.25",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.41.0",
|
"@playwright/test": "^1.41.0",
|
||||||
"@sveltejs/adapter-auto": "^3.3.1",
|
"@sveltejs/adapter-auto": "^3.3.1",
|
||||||
|
"@sveltejs/adapter-vercel": "^5.6.3",
|
||||||
"@sveltejs/kit": "^2.11.1",
|
"@sveltejs/kit": "^2.11.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ or
|
|||||||
```js
|
```js
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
import { NhostClient, NhostReactProvider } from '@nhost/react'
|
import { NhostClient, NhostProvider } from '@nhost/react'
|
||||||
import { NhostApolloProvider } from '@nhost/react-apollo'
|
import { NhostApolloProvider } from '@nhost/react-apollo'
|
||||||
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
@@ -38,11 +38,11 @@ const nhost = new NhostClient({
|
|||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<NhostReactProvider nhost={nhost}>
|
<NhostProvider nhost={nhost}>
|
||||||
<NhostApolloProvider nhost={nhost}>
|
<NhostApolloProvider nhost={nhost}>
|
||||||
<App />
|
<App />
|
||||||
</NhostApolloProvider>
|
</NhostApolloProvider>
|
||||||
</NhostReactProvider>
|
</NhostProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById('root')
|
document.getElementById('root')
|
||||||
)
|
)
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -56,10 +56,10 @@
|
|||||||
"dashboard"
|
"dashboard"
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.24.7",
|
"@babel/core": "^7.26.10",
|
||||||
"@babel/eslint-parser": "^7.24.7",
|
"@babel/eslint-parser": "^7.26.10",
|
||||||
"@babel/plugin-syntax-flow": "^7.24.7",
|
"@babel/plugin-syntax-flow": "^7.26.0",
|
||||||
"@babel/plugin-transform-react-jsx": "^7.24.7",
|
"@babel/plugin-transform-react-jsx": "^7.25.9",
|
||||||
"@changesets/cli": "^2.27.5",
|
"@changesets/cli": "^2.27.5",
|
||||||
"@faker-js/faker": "^7.6.0",
|
"@faker-js/faker": "^7.6.0",
|
||||||
"@rollup/plugin-replace": "^5.0.7",
|
"@rollup/plugin-replace": "^5.0.7",
|
||||||
@@ -82,6 +82,8 @@
|
|||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
|
"prettier-plugin-organize-imports": "^4.1.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||||
"turbo": "2.3.3",
|
"turbo": "2.3.3",
|
||||||
"typedoc": "^0.22.18",
|
"typedoc": "^0.22.18",
|
||||||
"typescript": "4.9.5",
|
"typescript": "4.9.5",
|
||||||
@@ -137,6 +139,8 @@
|
|||||||
"tough-cookie@<4.1.3": ">=4.1.3",
|
"tough-cookie@<4.1.3": ">=4.1.3",
|
||||||
"protobufjs@>=7.0.0 <7.2.4": ">=7.2.4",
|
"protobufjs@>=7.0.0 <7.2.4": ">=7.2.4",
|
||||||
"vite@=4.5.0": ">=4.5.1",
|
"vite@=4.5.0": ">=4.5.1",
|
||||||
|
"@babel/runtime@<7.26.10": ">=7.26.10",
|
||||||
|
"@babel/helpers@<7.26.10": ">=7.26.10",
|
||||||
"@babel/traverse@<7.23.2": ">=7.23.2",
|
"@babel/traverse@<7.23.2": ">=7.23.2",
|
||||||
"@adobe/css-tools@<4.3.2": ">=4.3.2",
|
"@adobe/css-tools@<4.3.2": ">=4.3.2",
|
||||||
"semver@<5.7.2": ">=5.7.2",
|
"semver@<5.7.2": ">=5.7.2",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nhost/docgen": "workspace:*",
|
"@nhost/docgen": "workspace:*",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"next": "^14.2.22",
|
"next": "^14.2.25",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export type CreateServerSideClientParams = Partial<
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an Nhost client that runs on the server side.
|
* Creates an Nhost client that runs on the server side.
|
||||||
* It will try to get the refesh token in cookies, or from the request URL
|
* It will try to get the refresh token in cookies, or from the request URL
|
||||||
* If a refresh token is found, it uses it to get an up to date access token (JWT) and a user session
|
* If a refresh token is found, it uses it to get an up to date access token (JWT) and a user session
|
||||||
* This method resolves when the authentication status is known eventually
|
* This method resolves when the authentication status is known eventually
|
||||||
* @param config - An object containing connection information
|
* @param config - An object containing connection information
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ Initialize a single `nhost` instance and wrap your app with the `NhostReactProvi
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom'
|
import ReactDOM from 'react-dom'
|
||||||
|
|
||||||
import { NhostClient, NhostReactProvider } from '@nhost/react'
|
import { NhostClient, NhostProvider } from '@nhost/react'
|
||||||
|
|
||||||
import App from './App'
|
import App from './App'
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ const nhost = new NhostClient({
|
|||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<NhostReactProvider nhost={nhost}>
|
<NhostProvider nhost={nhost}>
|
||||||
<App />
|
<App />
|
||||||
</NhostReactProvider>
|
</NhostProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById('root')
|
document.getElementById('root')
|
||||||
)
|
)
|
||||||
|
|||||||
2563
pnpm-lock.yaml
generated
2563
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
|||||||
"@craco/craco": "^7.1.0",
|
"@craco/craco": "^7.1.0",
|
||||||
"@hookform/resolvers": "^3.9.0",
|
"@hookform/resolvers": "^3.9.0",
|
||||||
"@icons-pack/react-simple-icons": "^9.6.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",
|
"@nhost/react-apollo": "^16.0.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.1",
|
"@radix-ui/react-dialog": "^1.1.1",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
"@radix-ui/react-dropdown-menu": "^2.1.1",
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
"@nhost-examples/sveltekit#build": {
|
"@nhost-examples/sveltekit#build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": [".svelte-kit/**", ".vercel/**"],
|
"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": {
|
"@nhost-examples/vue-apollo#build": {
|
||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
|
|||||||
Reference in New Issue
Block a user