Compare commits

...

7 Commits

Author SHA1 Message Date
github-actions[bot]
60d4d28627 chore: update versions (#3239)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## @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

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-25 08:39:49 +01:00
David BM
34fdcb8863 chore (changeset): add dashboard prettier plugins to root of monorepo as devDependencies (#3252)
### **PR Type**
Enhancement


___

### **Description**
- Add Prettier plugins to root monorepo as devDependencies

- Include Prettier plugin for organizing imports

- Add Prettier plugin for Tailwind CSS

- Update changeset for @nhost/dashboard minor version bump


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Documentation</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>angry-rabbits-fly.md</strong><dd><code>Add changeset
for Prettier plugins addition</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></summary>
<hr>

.changeset/angry-rabbits-fly.md

<li>Add new changeset file for @nhost/dashboard<br> <li> Specify minor
version bump<br> <li> Describe addition of Prettier plugins as
devDependencies


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/3252/files#diff-1dc91a69351de73036aee86088b5553c604a0b7b726d1134bc679c71e288eea8">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></td></tr><tr><td><strong>Dependencies</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>package.json</strong><dd><code>Add Prettier plugins to
package.json</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

package.json

<li>Add prettier-plugin-organize-imports as devDependency<br> <li> Add
prettier-plugin-tailwindcss as devDependency


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/3252/files#diff-7ae45ad102eab3b6d7e7896acd08c427a9b25b346470d7bc6507b6481575d519">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></td></tr></tr></tbody></table>

___

> <details> <summary> Need help?</summary><li>Type <code>/help how to
...</code> in the comments thread for any questions about PR-Agent
usage.</li><li>Check out the <a
href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a>
for more information.</li></details>
2025-03-24 19:44:17 +01:00
robertkasza
78436ca29e chore (dashboard): update PiTR (#3247)
- add tests
- add price to PiTR settings
- update link in PiTR settings
- add note with recommendation about restore
- add link to PiTR docs to restore page
- small bug fixes
2025-03-24 15:53:34 +01:00
robertkasza
ea6584614b chore (dashboard): update nextjs to fix middleware exploit (#3251)
### **User description**
More info:
https://zeropath.com/blog/nextjs-middleware-cve-2025-29927-auth-bypass


___

### **PR Type**
Enhancement


___

### **Description**
- Update Next.js to version 14.2.25

- Address middleware exploit vulnerability

- Improve dashboard security


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Documentation</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>nervous-shirts-rush.md</strong><dd><code>Add changeset
for Next.js update</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
</dd></summary>
<hr>

.changeset/nervous-shirts-rush.md

<li>Add new changeset file<br> <li> Specify patch update for
'@nhost/dashboard'<br> <li> Include description of Next.js update


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/3251/files#diff-f9ef884a817466b3e2f2e10938fd046e15c764241ea5a8b841e0fea8cb2242e9">+5/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></td></tr><tr><td><strong>Dependencies</strong></td><td><table>
<tr>
  <td>
    <details>
<summary><strong>package.json</strong><dd><code>Upgrade Next.js to
version 14.2.25</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></summary>
<hr>

dashboard/package.json

- Update Next.js dependency from 14.2.22 to 14.2.25


</details>


  </td>
<td><a
href="https://github.com/nhost/nhost/pull/3251/files#diff-2d8d55c799cd71f1b35e831f075f8178ed1734c4820a2ad548b4dd24d6938d7c">+1/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>
</table></td></tr></tr></tbody></table>

___

> <details> <summary> Need help?</summary><li>Type <code>/help how to
...</code> in the comments thread for any questions about PR-Agent
usage.</li><li>Check out the <a
href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a>
for more information.</li></details>
2025-03-24 10:35:04 +01:00
David BM
4937c5e055 fix (dashboard): stop content overflowing in projects and database permissions page (#3240) 2025-03-21 12:05:39 +01:00
robertkasza
b5a3895e16 chore (dashboard): update page context after each navigation (#3248)
### **PR Type**
Tests


___

### **Description**
- Added `updatePageContext` utility function for consistent page context
updates.

- Refactored e2e tests to use `updatePageContext` and `gotoAuthURL`.

- Improved test setup by centralizing navigation logic.

- Enhanced maintainability of e2e tests with reusable utilities.


___



### **Changes walkthrough** 📝
<table><thead><tr><th></th><th align="left">Relevant
files</th></tr></thead><tbody><tr><td><strong>Tests</strong></td><td><details><summary>13
files</summary><table>
<tr>
<td><strong>manage-pat.test.ts</strong><dd><code>Integrated
`updatePageContext` in PAT management tests</code>&nbsp; &nbsp; &nbsp;
</dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-891790fa0d9b0e0b23b12af547a6dc7736fad9eaf76b14a56f310e531e6db098">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>assistants.test.ts</strong><dd><code>Added
`updatePageContext` to AI assistants tests</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-95533e004b514add57a2c87201a68cac11c20ffa458afd78e045ed89559e7546">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>auto-embeddings.test.ts</strong><dd><code>Added
`updatePageContext` to auto-embeddings tests</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-d3a5b860634fd36dd33ac9236210632eb5f8ad322aa15bedfc61a8e2c60dbd68">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>ban-user.test.ts</strong><dd><code>Refactored ban-user tests
with `gotoAuthURL`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-8d8d853b89f4a44454e4400182cbfe900f3c15eebe04d43a8d43f9c782b39f57">+16/-6</a>&nbsp;
&nbsp; </td>

</tr>

<tr>
<td><strong>create-user.test.ts</strong><dd><code>Refactored create-user
tests with `gotoAuthURL`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-b5d83f9ceb9d621a5fe72789a6c961773548d7f459c72fad953b2a09694ff0a7">+2/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>delete-user.test.ts</strong><dd><code>Refactored delete-user
tests with `gotoAuthURL`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-a9d249f139e75a681888115b925e171c856c94f99c4077be6d954be4e58e0d74">+2/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>edit-user.test.ts</strong><dd><code>Refactored edit-user
tests with `gotoAuthURL`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-fc232b4d225c1367489733ede6bf5ebe88967b0353aa76c88c5e712c35b31be5">+2/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>verify-user.test.ts</strong><dd><code>Refactored verify-user
tests with `gotoAuthURL`</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-a8425690a42ed772a97d3a17f062cb5713cc3180032c1d5eb1ef3f6d55cc110e">+2/-5</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>create-table.test.ts</strong><dd><code>Added
`updatePageContext` to create-table tests</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-1e7aa9f3e379ca90a94b82c14be48e2c98a722d85ee1b0785a082b7076d8e58c">+6/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>delete-table.test.ts</strong><dd><code>Added
`updatePageContext` to delete-table tests</code>&nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-9e8c87f8e8f11bcfa2b7b2e5cf9dffe54a0fdeb3385ccb82b74e4e1c18fb9c43">+7/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>permissions-table.test.ts</strong><dd><code>Added
`updatePageContext` to permissions-table tests</code>&nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-f4b586f5b8f3bb97ddf64f8f38c461ac0424e101789f61e325d1b80bb8dc1047">+2/-0</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>overview.test.ts</strong><dd><code>Integrated
`updatePageContext` in overview tests</code>&nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-4c6f1ff0b9d3b7fc7517aa50d9002bed56902f5b31557fa460f633f98da9cf01">+4/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

<tr>
<td><strong>run.test.ts</strong><dd><code>Integrated `updatePageContext`
in run tests</code>&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; </dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-3b81821630a8e66e8f580609a834499bdfec9ac228ff07b99f398ec07c329095">+2/-1</a>&nbsp;
&nbsp; &nbsp; </td>

</tr>

</table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>1
files</summary><table>
<tr>
<td><strong>utils.ts</strong><dd><code>Added `updatePageContext` and
`gotoAuthURL` utility functions</code></dd></td>
<td><a
href="https://github.com/nhost/nhost/pull/3248/files#diff-490448aa83585151d8c61d698273c43486fdcac6a5d28a9b7e5be2729bbffd12">+13/-0</a>&nbsp;
&nbsp; </td>

</tr>
</table></details></td></tr></tr></tbody></table>

___

> <details> <summary> Need help?</summary><li>Type <code>/help how to
...</code> in the comments thread for any questions about PR-Agent
usage.</li><li>Check out the <a
href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a>
for more information.</li></details>
2025-03-20 14:46:42 +01:00
Alex
9b24807562 fix (dashboard) Update DatabasePiTRSettings.tsx to point to actual PiTR documentation (#3243)
Updated the docsLink of `DatabasePiTRSettings.tsx` to point to the
actual documentation of PiTR

---------

Co-authored-by: David Barroso <dbarrosop@dravetech.com>
2025-03-18 12:31:53 +01:00
60 changed files with 1915 additions and 613 deletions

View File

@@ -1,5 +0,0 @@
---
'@nhost/dashboard': minor
---
fix: update babel dependencies to address security audit vulnerabilities

View File

@@ -1,5 +1,20 @@
# @nhost/dashboard
## 2.25.0
### Minor Changes
- 34fdcb8: chore: add prettier plugins as devDependencies to root of monorepo
- 4937c5e: fix: stop content overflowing in projects and database permissions page
- 1542132: fix: update babel dependencies to address security audit vulnerabilities
### Patch Changes
- 78436ca: chore (dashboard): add tests and small updates to PiTR settings and restore page
- b5a3895: chore (dashboard): update page context after each navigation
- 9b24807: chore: fix link to PiTR documentation
- ea65846: chore (dashboard): update nextjs to fix middleware exploit
## 2.17.0
### Minor Changes

View File

@@ -1,22 +1,10 @@
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
});
test.afterAll(async () => {
await page.close();
});
test('should be able to create then delete a personal access token', async () => {
test('should be able to create then delete a personal access token', async ({
authenticatedNhostPage: page,
}) => {
await page.waitForTimeout(1000);
await page.getByRole('banner').getByRole('button').last().click();
await page.getByRole('link', { name: /account settings/i }).click();

View File

@@ -1,17 +1,8 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { navigateToProject } from '@/e2e/utils';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
@@ -23,11 +14,9 @@ test.beforeEach(async () => {
await page.waitForURL(AIRoute);
});
test.afterAll(async () => {
await page.close();
});
test('should create and delete an Assistant', async () => {
test('should create and delete an Assistant', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('link', { name: 'Assistants' }).click();
await expect(page.getByText(/no assistants are configured/i)).toBeVisible();

View File

@@ -1,17 +1,9 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { navigateToProject } from '@/e2e/utils';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
import { expect, test } from '@/e2e/fixtures/auth-hook';
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
@@ -23,11 +15,9 @@ test.beforeEach(async () => {
await page.waitForURL(AIRoute);
});
test.afterAll(async () => {
await page.close();
});
test('should create and delete an Auto-Embeddings', async () => {
test('should create and delete an Auto-Embeddings', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
await page.getByLabel('Name').fill('test');

View File

@@ -1,13 +1,14 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail } from '@/e2e/utils';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import test, { expect } from '@playwright/test';
test('should be able to ban and unban a user', async ({ page }) => {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await gotoAuthURL(page);
});
test('should be able to ban and unban a user', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();

View File

@@ -1,26 +1,12 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail } from '@/e2e/utils';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await gotoAuthURL(page);
});
test.beforeEach(async () => {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should create a user', async () => {
test('should create a user', async ({ authenticatedNhostPage: page }) => {
const email = generateTestEmail();
const password = faker.internet.password();
@@ -31,7 +17,9 @@ test('should create a user', async () => {
).toBeVisible();
});
test('should not be able to create a user with an existing email', async () => {
test('should not be able to create a user with an existing email', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();

View File

@@ -1,26 +1,15 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail } from '@/e2e/utils';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
let page: Page;
import { expect, test } from '@/e2e/fixtures/auth-hook';
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await gotoAuthURL(page);
});
test.beforeEach(async () => {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should be able to delete a user', async () => {
test('should be able to delete a user', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();
@@ -52,7 +41,9 @@ test('should be able to delete a user', async () => {
).not.toBeVisible();
});
test('should be able to delete a user from the details page', async () => {
test('should be able to delete a user from the details page', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();

View File

@@ -1,26 +1,14 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail } from '@/e2e/utils';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import test, { expect } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await gotoAuthURL(page);
});
test.beforeEach(async () => {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should be able to edit user roles from the details page', async () => {
test('should be able to edit user roles from the details page', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();

View File

@@ -1,26 +1,14 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { createUser, generateTestEmail } from '@/e2e/utils';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await gotoAuthURL(page);
});
test.beforeEach(async () => {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
});
test.afterAll(async () => {
await page.close();
});
test('should be able to verify the email of a user', async () => {
test('should be able to verify the email of a user', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();
@@ -50,7 +38,9 @@ test('should be able to verify the email of a user', async () => {
).toBeChecked();
});
test('should be able to verify the phone number of a user', async () => {
test('should be able to verify the phone number of a user', async ({
authenticatedNhostPage: page,
}) => {
const email = generateTestEmail();
const password = faker.internet.password();
const phoneNumber = faker.phone.number();

View File

@@ -1,35 +1,18 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { navigateToProject, prepareTable } from '@/e2e/utils';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute);
await page.waitForURL(databaseRoute);
});
test.afterAll(async () => {
await page.close();
});
test('should create a simple table', async () => {
test('should create a simple table', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -57,7 +40,9 @@ test('should create a simple table', async () => {
).toBeVisible();
});
test('should create a table with unique constraints', async () => {
test('should create a table with unique constraints', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -86,7 +71,9 @@ test('should create a table with unique constraints', async () => {
).toBeVisible();
});
test('should create a table with nullable columns', async () => {
test('should create a table with nullable columns', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -115,7 +102,9 @@ test('should create a table with nullable columns', async () => {
).toBeVisible();
});
test('should create a table with an identity column', async () => {
test('should create a table with an identity column', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -148,7 +137,9 @@ test('should create a table with an identity column', async () => {
).toBeVisible();
});
test('should create table with foreign key constraint', async () => {
test('should create table with foreign key constraint', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -221,7 +212,9 @@ test('should create table with foreign key constraint', async () => {
).toBeVisible();
});
test('should not be able to create a table with a name that already exists', async () => {
test('should not be able to create a table with a name that already exists', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();

View File

@@ -1,35 +1,17 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { deleteTable, navigateToProject, prepareTable } from '@/e2e/utils';
import { deleteTable, prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { snakeCase } from 'snake-case';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute);
await page.waitForURL(databaseRoute);
});
test.afterAll(async () => {
await page.close();
});
test('should delete a table', async () => {
test('should delete a table', async ({ authenticatedNhostPage: page }) => {
const tableName = snakeCase(faker.lorem.words(3));
await page.getByRole('button', { name: /new table/i }).click();
@@ -65,7 +47,9 @@ test('should delete a table', async () => {
).not.toBeVisible();
});
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
test('should not be able to delete a table if other tables have foreign keys referencing it', async ({
authenticatedNhostPage: page,
}) => {
test.setTimeout(60000);
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();

View File

@@ -1,39 +1,18 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import {
clickPermissionButton,
navigateToProject,
prepareTable,
} from '@/e2e/utils';
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { clickPermissionButton, prepareTable } from '@/e2e/utils';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { snakeCase } from 'snake-case';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute);
await page.waitForURL(databaseRoute);
});
test.afterAll(async () => {
await page.close();
});
test('should create a table with role permissions to select row', async () => {
test('should create a table with role permissions to select row', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();
@@ -79,7 +58,9 @@ test('should create a table with role permissions to select row', async () => {
).toBeVisible();
});
test('should create a table with role permissions and a custom check to select rows', async () => {
test('should create a table with role permissions and a custom check to select rows', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: /new table/i }).click();
await expect(page.getByText(/create a new table/i)).toBeVisible();

View 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';

View File

@@ -1,15 +1,8 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { navigateToProject } from '../utils';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
await page.goto('/');
import { expect, test } from '@/e2e/fixtures/auth-hook';
import { navigateToProject } from '@/e2e/utils';
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
@@ -17,11 +10,9 @@ test.beforeAll(async ({ browser }) => {
});
});
test.afterAll(async () => {
await page.close();
});
test('should show the navtree with all links visible', async () => {
test('should show the navtree with all links visible', async ({
authenticatedNhostPage: page,
}) => {
const navLocator = page.getByLabel('Navigation Tree');
await expect(navLocator).toBeVisible();
@@ -42,16 +33,20 @@ test('should show the navtree with all links visible', async () => {
'Settings',
];
// eslint-disable-next-line no-restricted-syntax
for (const linkName of links) {
const link =
linkName === 'Settings'
? page.getByRole('link', { name: linkName }).first()
: page.getByRole('link', { name: linkName });
// eslint-disable-next-line no-await-in-loop
await expect(link).toBeVisible();
}
});
test("should show the project's region and subdomain", async () => {
test("should show the project's region and subdomain", async ({
authenticatedNhostPage: page,
}) => {
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
/frankfurt \(eu-central-1\)/i,
);
@@ -60,7 +55,9 @@ test("should show the project's region and subdomain", async () => {
).toHaveText(/[a-z]{20}/i);
});
test('should not have a GitHub repository connected', async () => {
test('should not have a GitHub repository connected', async ({
authenticatedNhostPage: page,
}) => {
await expect(
page.getByRole('button', { name: /connect to github/i }).first(),
).toBeVisible();

View File

@@ -1,33 +1,15 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import type { Page } from '@playwright/test';
import { expect, test } from '@playwright/test';
import { navigateToProject } from '../utils';
let page: Page;
test.beforeAll(async ({ browser }) => {
page = await browser.newPage();
});
test.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
import { expect, test } from '@/e2e/fixtures/auth-hook';
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`;
await page.goto(runRoute);
await page.waitForURL(runRoute);
});
test.afterAll(async () => {
await page.close();
});
test('should create and delete a run service', async () => {
test('should create and delete a run service', async ({
authenticatedNhostPage: page,
}) => {
await page.getByRole('button', { name: 'Add service' }).first().click();
await expect(page.getByText(/create a new service/i)).toBeVisible();
await page.getByPlaceholder(/service name/i).click();

View File

@@ -1,49 +1,23 @@
import {
TEST_DASHBOARD_URL,
TEST_ORGANIZATION_SLUG,
TEST_PROJECT_SUBDOMAIN,
} from '@/e2e/env';
import { navigateToProject } from '@/e2e/utils';
import { type Page, expect, test as teardown } from '@playwright/test';
let page: Page;
teardown.beforeAll(async ({ browser }) => {
const context = await browser.newContext({
baseURL: TEST_DASHBOARD_URL,
storageState: 'e2e/.auth/user.json',
});
page = await context.newPage();
});
teardown.beforeEach(async () => {
await page.goto('/');
await navigateToProject({
page,
orgSlug: TEST_ORGANIZATION_SLUG,
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
});
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { expect, test as teardown } from '@/e2e/fixtures/auth-hook';
teardown.beforeEach(async ({ authenticatedNhostPage: page }) => {
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
await page.goto(databaseRoute);
await page.waitForURL(databaseRoute);
});
teardown.afterAll(async () => {
await page.close();
});
teardown(
'clean up database tables',
async ({ authenticatedNhostPage: page }) => {
await page.getByRole('link', { name: /sql editor/i }).click();
teardown('clean up database tables', async () => {
await page.getByRole('link', { name: /sql editor/i }).click();
await page.waitForURL(
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
);
await page.waitForURL(
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
);
const inputField = page.locator('[contenteditable]');
await inputField.fill(`
const inputField = page.locator('[contenteditable]');
await inputField.fill(`
DO $$ DECLARE
tablename text;
BEGIN
@@ -56,6 +30,7 @@ teardown('clean up database tables', async () => {
END $$;
`);
await page.locator('button[type="button"]', { hasText: /run/i }).click();
await expect(page.getByText(/success/i)).toBeVisible();
});
await page.locator('button[type="button"]', { hasText: /run/i }).click();
await expect(page.getByText(/success/i)).toBeVisible();
},
);

View File

@@ -1,3 +1,4 @@
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
import { faker } from '@faker-js/faker';
import type { Page } from '@playwright/test';
@@ -211,3 +212,9 @@ export async function clickPermissionButton({
.locator('button')
.click();
}
export async function gotoAuthURL(page) {
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
await page.goto(authUrl);
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
}

View File

@@ -1,6 +1,6 @@
{
"name": "@nhost/dashboard",
"version": "2.24.0",
"version": "2.25.0",
"private": true,
"scripts": {
"preinstall": "npx only-allow pnpm",
@@ -87,7 +87,7 @@
"just-kebab-case": "^4.2.0",
"lodash.debounce": "^4.0.8",
"lucide-react": "^0.416.0",
"next": "^14.2.22",
"next": "^14.2.25",
"next-nprogress-bar": "^2.3.13",
"next-seo": "^6.5.0",
"next-themes": "^0.3.0",
@@ -96,7 +96,7 @@
"react": "18.2.0",
"react-children-utilities": "^2.10.0",
"react-complex-tree": "^2.4.5",
"react-day-picker": "8.10.1",
"react-day-picker": "9.6.3",
"react-dom": "18.2.0",
"react-error-boundary": "^4.0.13",
"react-hook-form": "^7.53.0",

View File

@@ -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',
);
});
});

View File

@@ -27,8 +27,6 @@ export interface DateTimePickerProps {
align?: 'start' | 'center' | 'end';
validateDateFn?: (date: Date) => string;
}
// in: UTC datetime
// out: UTC dateTime
function DateTimePicker({
dateTime,
@@ -49,6 +47,10 @@ function DateTimePicker({
});
const [open, setOpen] = useState(false);
const [timezone, setTimezone] = useState(
() => defaultTimezone || guessTimezone(),
);
function emitNewDateTime() {
onDateTimeChange(new Date(date.getTime()).toISOString());
}
@@ -73,6 +75,7 @@ function DateTimePicker({
function handleTimezoneChange(newTimezone: string) {
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
setTimezone(newTimezone);
setDate(newDateWithTimezone);
}
@@ -80,6 +83,7 @@ function DateTimePicker({
if (!newOpenState) {
if (withTimezone) {
const tz = defaultTimezone || guessTimezone();
setTimezone(tz);
setDate(new TZDate(dateTime, tz));
}
setDate(parseISO(dateTime));
@@ -92,6 +96,8 @@ function DateTimePicker({
setOpen(false);
}
const selectedDateInUTC = new Date(date.getTime()).toISOString();
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
const errorText = validateDateFn?.(date);
@@ -101,6 +107,7 @@ function DateTimePicker({
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button
data-testid="dateTimePickerTrigger"
variant="outline"
className={cn(
'w-full justify-between text-left font-normal',
@@ -113,6 +120,7 @@ function DateTimePicker({
<CalendarIcon className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align={align}>
<div className="flex">
<div className="flex">
@@ -120,8 +128,8 @@ function DateTimePicker({
mode="single"
selected={date}
onSelect={(d) => handleSelect(d)}
initialFocus
disabled={isCalendarDayDisabled}
timeZone={timezone}
/>
<div className="flex flex-col justify-between">
<div>
@@ -131,7 +139,7 @@ function DateTimePicker({
{withTimezone && (
<div className="border-t border-border p-3">
<TimezoneSettings
dateTime={dateTime}
dateTime={selectedDateInUTC}
onTimezoneChange={handleTimezoneChange}
/>
</div>

View File

@@ -18,16 +18,23 @@ function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
setTimezone(tz.value);
onTimezoneChange?.(tz.value);
}
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
return (
<div className="flex w-full items-center justify-between">
Timezone: {utcOffset}{' '}
<span>Timezone: {utcOffset}</span>
<TimezonePicker
dateTime={dateTime}
selectedTimezone={selectedTimezone}
onTimezoneSelect={handleTimezoneSelect}
button={
<Button variant="ghost" size="icon">
<Button
variant="ghost"
size="icon"
aria-label="Open timezone settings"
data-testid="timezoneSettingsButton"
>
<Settings2 className="h-4 w-4 dark:text-foreground" />
</Button>
}

View File

@@ -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;
}

View File

@@ -9,6 +9,26 @@ interface Props {
dateTime: string;
}
function getOrderedTimezones(dateTime: string, selectedTimezone: string) {
const [utcTimezone, browserTimezone, ...timezones] =
createTimezoneOptions(dateTime);
let orderedTimezones = [...timezones];
if (
selectedTimezone !== browserTimezone.value &&
selectedTimezone !== 'UTC'
) {
const selectedTimezoneOption = timezones.find(
(tz) => tz.value === selectedTimezone,
);
orderedTimezones = [
selectedTimezoneOption,
...timezones.filter((tz) => tz.value !== selectedTimezone),
];
}
return [utcTimezone, browserTimezone, ...orderedTimezones];
}
function TimezonePicker({
selectedTimezone,
onTimezoneSelect,
@@ -16,9 +36,10 @@ function TimezonePicker({
dateTime,
}: Props) {
const timezoneOptions = useMemo(
() => createTimezoneOptions(dateTime),
[dateTime],
() => getOrderedTimezones(dateTime, selectedTimezone),
[dateTime, selectedTimezone],
);
return (
<VirtualizedCombobox
options={timezoneOptions}
@@ -27,6 +48,7 @@ function TimezonePicker({
searchPlaceholder="Search timezones..."
button={button}
side="right"
width="370px"
/>
);
}

View File

@@ -105,20 +105,12 @@ function VirtualizedCommand<O extends Option>({
}
};
React.useEffect(() => {
if (selectedOption) {
const option = filteredOptions.find(
(opt) => opt.value === selectedOption,
);
if (option) {
const index = filteredOptions.indexOf(option);
setFocusedIndex(index);
}
}
}, [selectedOption, filteredOptions, virtualizer]);
return (
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
<Command
shouldFilter={false}
onKeyDown={handleKeyDown}
value={selectedOption}
>
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
<CommandList
ref={parentRef}
@@ -145,7 +137,6 @@ function VirtualizedCommand<O extends Option>({
filteredOptions[virtualOption.index].key ??
filteredOptions[virtualOption.index].value
}
disabled={isKeyboardNavActive}
className={cn(
'absolute left-0 top-0 w-full bg-transparent',
focusedIndex === virtualOption.index &&

View File

@@ -1,75 +1,174 @@
'use client';
/* eslint-disable react/no-unstable-nested-components */
import { ChevronLeft, ChevronRight } from 'lucide-react';
import {
DayPicker,
type DayPickerProps,
type StyledComponent,
} from 'react-day-picker';
'use client';
import { buttonVariants } from '@/components/ui/v3/button';
import { cn } from '@/lib/utils';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DayPicker, type DayPickerProps } from 'react-day-picker';
const IconLeft = ({ className, ...props }: StyledComponent) => (
<ChevronLeft className={cn('h-4 w-4', className)} {...props} />
);
const IconRight = ({ className, ...props }: StyledComponent) => (
<ChevronRight className={cn('h-4 w-4', className)} {...props} />
);
export type CalendarProps = DayPickerProps & {
/**
* In the year view, the number of years to display at once.
* @default 12
*/
yearRange?: number;
/**
* Wether to show the year switcher in the caption.
* @default true
*/
showYearSwitcher?: boolean;
monthsClassName?: string;
monthCaptionClassName?: string;
weekdaysClassName?: string;
weekdayClassName?: string;
monthClassName?: string;
captionClassName?: string;
captionLabelClassName?: string;
buttonNextClassName?: string;
buttonPreviousClassName?: string;
navClassName?: string;
monthGridClassName?: string;
weekClassName?: string;
dayClassName?: string;
dayButtonClassName?: string;
rangeStartClassName?: string;
rangeEndClassName?: string;
selectedClassName?: string;
todayClassName?: string;
outsideClassName?: string;
disabledClassName?: string;
rangeMiddleClassName?: string;
hiddenClassName?: string;
};
/**
* A custom calendar component built on top of react-day-picker.
* @param props The props for the calendar.
* @default yearRange 12
* @returns
*/
function Calendar({
className,
classNames,
showOutsideDays = true,
numberOfMonths,
...props
}: DayPickerProps) {
}: CalendarProps) {
const monthsClassName = cn('relative flex', props.monthsClassName);
const monthCaptionClassName = cn(
'relative mx-10 flex h-7 items-center justify-center',
props.monthCaptionClassName,
);
const weekdaysClassName = cn('flex flex-row', props.weekdaysClassName);
const weekdayClassName = cn(
'w-8 text-sm font-normal text-muted-foreground',
props.weekdayClassName,
);
const monthClassName = cn('w-full', props.monthClassName);
const captionClassName = cn(
'relative flex items-center justify-center pt-1',
props.captionClassName,
);
const captionLabelClassName = cn(
'truncate text-sm font-medium',
props.captionLabelClassName,
);
const buttonNavClassName = buttonVariants({
variant: 'outline',
className:
'absolute h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
});
const buttonNextClassName = cn(
buttonNavClassName,
'right-0',
props.buttonNextClassName,
);
const buttonPreviousClassName = cn(
buttonNavClassName,
'left-0',
props.buttonPreviousClassName,
);
const navClassName = cn('flex items-start', props.navClassName);
const monthGridClassName = cn('mx-auto mt-4', props.monthGridClassName);
const weekClassName = cn('mt-2 flex w-max items-start', props.weekClassName);
const dayClassName = cn(
'flex size-8 flex-1 items-center justify-center p-0 text-sm',
props.dayClassName,
);
const dayButtonClassName = cn(
buttonVariants({ variant: 'ghost' }),
'size-8 rounded-md p-0 font-normal transition-none aria-selected:opacity-100',
props.dayButtonClassName,
);
const buttonRangeClassName =
'bg-accent [&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground';
const rangeStartClassName = cn(
buttonRangeClassName,
'day-range-start rounded-s-md',
props.rangeStartClassName,
);
const rangeEndClassName = cn(
buttonRangeClassName,
'day-range-end rounded-e-md',
props.rangeEndClassName,
);
const rangeMiddleClassName = cn(
'bg-accent !text-foreground [&>button]:bg-transparent [&>button]:!text-foreground [&>button]:hover:bg-transparent [&>button]:hover:!text-foreground',
props.rangeMiddleClassName,
);
const selectedClassName = cn(
'[&>button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground',
props.selectedClassName,
);
const todayClassName = cn(
'[&>button]:bg-accent [&>button]:text-accent-foreground',
props.todayClassName,
);
const outsideClassName = cn(
'day-outside text-muted-foreground opacity-50 aria-selected:bg-accent/50 aria-selected:text-muted-foreground aria-selected:opacity-30',
props.outsideClassName,
);
const disabledClassName = cn(
'text-muted-foreground opacity-50',
props.disabledClassName,
);
const hiddenClassName = cn('invisible flex-1', props.hiddenClassName);
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn('p-3', className)}
classNames={{
months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
month: 'space-y-4',
caption: 'flex justify-center pt-1 relative items-center',
caption_label: 'text-sm font-medium',
nav: 'space-x-1 flex items-center',
nav_button: cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
),
nav_button_previous: 'absolute left-1',
nav_button_next: 'absolute right-1',
table: 'w-full border-collapse space-y-1',
head_row: 'flex',
head_cell:
'text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]',
row: 'flex w-full mt-2',
cell: cn(
'relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md',
props.mode === 'range'
? '[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md'
: '[&:has([aria-selected])]:rounded-md',
),
day: cn(
buttonVariants({ variant: 'ghost' }),
'h-8 w-8 p-0 font-normal aria-selected:opacity-100',
),
day_range_start: 'day-range-start',
day_range_end: 'day-range-end',
day_selected:
'bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground',
day_today: 'bg-accent text-accent-foreground',
day_outside:
'day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground',
day_disabled: 'text-muted-foreground opacity-50',
day_range_middle:
'aria-selected:bg-accent aria-selected:text-accent-foreground',
day_hidden: 'invisible',
...classNames,
months: monthsClassName,
month_caption: monthCaptionClassName,
weekdays: weekdaysClassName,
weekday: weekdayClassName,
month: monthClassName,
caption: captionClassName,
caption_label: captionLabelClassName,
button_next: buttonNextClassName,
button_previous: buttonPreviousClassName,
nav: navClassName,
month_grid: monthGridClassName,
week: weekClassName,
day: dayClassName,
day_button: dayButtonClassName,
range_start: rangeStartClassName,
range_middle: rangeMiddleClassName,
range_end: rangeEndClassName,
selected: selectedClassName,
today: todayClassName,
outside: outsideClassName,
disabled: disabledClassName,
hidden: hiddenClassName,
}}
components={{
IconLeft,
IconRight,
Chevron: ({ orientation }) => {
const Icon = orientation === 'left' ? ChevronLeft : ChevronRight;
return <Icon className="h-4 w-4" />;
},
}}
{...props}
/>

View File

@@ -35,16 +35,23 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
);
};
interface CommandInputProps {
prefix?: React.ReactNode;
prefixClassName?: string;
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
prefix?: React.ReactNode;
}
>(({ className, prefix, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> &
CommandInputProps
>(({ className, prefix, prefixClassName, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
{prefix && (
<span className="pointer-events-none flex items-center text-muted-foreground">
<span
title={prefix}
className={cn('text-muted-foreground', prefixClassName)}
>
{prefix}
</span>
)}

View File

@@ -5,24 +5,33 @@ import { type PropsWithChildren, type ReactNode } from 'react';
interface Props {
title?: string;
icon?: ReactNode;
borderLess?: boolean;
}
function InfoAlert({ children, title, icon }: PropsWithChildren<Props>) {
function InfoAlert({
children,
title,
icon,
borderLess = false,
}: PropsWithChildren<Props>) {
const alertClassNames = cn('bg-[#ebf3ff] dark:bg-muted', {
'flex gap-2 items-center': !!icon,
'border-none': borderLess,
});
const descClassNames = cn('text-[0.9375rem] leading-[22px]', {
'text-[0.875rem] leading-[1rem]': !!icon,
const descClassNames = cn('text-[0.9375rem] leading-6', {
'text-[0.875rem] leading-6': !!icon,
});
return (
<Alert className={alertClassNames}>
{icon && <div>{icon}</div>}
<div>
{title && <AlertTitle>{title}</AlertTitle>}
<AlertDescription className={descClassNames}>
{children}
</AlertDescription>
{children && (
<AlertDescription className={descClassNames}>
{children}
</AlertDescription>
)}
</div>
</Alert>
);

View File

@@ -57,6 +57,7 @@ const getUseRouterObject = (session_id?: string) => ({
},
isFallback: false,
});
const mocks = vi.hoisted(() => ({
useRouter: vi.fn(),
useOrgs: vi.fn(),

View File

@@ -27,8 +27,10 @@ function ProjectCard({ project }: { project: Project }) {
>
<div className="flex flex-row items-start gap-2">
<Box className="mt-[2px] h-5 w-5 flex-shrink-0" />
<div className="flex w-full flex-col">
<p className="truncate font-bold">{project.name}</p>
<div className="flex w-full flex-col overflow-hidden">
<p title={project.name} className="truncate font-bold">
{project.name}
</p>
<span className="text-xs text-muted-foreground">
{project.region.name}
</span>

View File

@@ -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();
});
});

View File

@@ -8,11 +8,12 @@ function BackupsContent({ isPiTREnabled }: { isPiTREnabled: boolean }) {
const [tab, setTab] = useState(() =>
isPiTREnabled ? 'pointInTime' : 'scheduledBackups',
);
return (
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="scheduledBackups">Scheduled backups</TabsTrigger>
<TabsTrigger value="pointInTime">Point-in-time</TabsTrigger>
<TabsTrigger value="pointInTime">Point-in-Time</TabsTrigger>
<TabsTrigger value="importBackup">Import backup</TabsTrigger>
</TabsList>
<div className="pt-7">

View File

@@ -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();
});
});

View File

@@ -4,7 +4,7 @@ import { DatabaseZap } from 'lucide-react';
function PiTRNotEnabledOnSourceProject() {
return (
<InfoAlert
title="Point-in-Time recovery is not enabled on the selected project"
title="Point-in-Time Recovery is not enabled on the selected project"
icon={<DatabaseZap className="h-[38px] w-[38px]" />}
>
Importing from scheduled backups is not supported yet. Coming soon!

View File

@@ -1,13 +1,15 @@
import { PointInTimeBackupInfo } from '@/features/orgs/projects/backups/components/common/PointInTimeBackupInfo';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import RecoveryRetentionPeriod from './RecoveryRetentionPeriod';
import RestoreRecommendationNote from './RestoreRecommendationNote';
function PointInTimeRecovery() {
const { project } = useProject();
return (
<div className="flex flex-col gap-[1.875rem]">
<RecoveryRetentionPeriod />
<PointInTimeBackupInfo appId={project?.id} />
<RestoreRecommendationNote />
<PointInTimeBackupInfo appId={project?.id} showLink />
</div>
);
}

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -3,8 +3,9 @@ import { InfoAlert } from '@/features/orgs/components/InfoAlert';
function PiTREnabledInfoBanner() {
return (
<InfoAlert>
With PiTR enabled, Scheduled backups are no longer taken. PiTR provides
more precise recovery, making additional backups unnecessary.
With Point-in-Time Recovery enabled, Scheduled backups are no longer
taken. Point-in-Time Recovery provides more precise recovery, making
additional backups unnecessary.
</InfoAlert>
);
}

View File

@@ -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();
});
});

View File

@@ -1,22 +1,8 @@
import { Button } from '@/components/ui/v3/button';
import { DialogFooter } from '@/components/ui/v3/dialog';
import Link from 'next/link';
import type { PropsWithChildren } from 'react';
import TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
import { memo } from 'react';
function LogsLink({ href, children }: PropsWithChildren<{ href: string }>) {
return (
<Link
href={href}
className="text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
target="_blank"
rel="noopener noreferrer"
>
{children}
</Link>
);
}
interface Props {
onClose: () => void;
orgSlug: string;
@@ -29,10 +15,10 @@ function BackupScheduledInfo({ onClose, orgSlug, subdomain }: Props) {
<p>Your backup has been scheduled successfully and will start shortly.</p>
<p>
To follow its process go to the{' '}
<LogsLink href={`/orgs/${orgSlug}/projects/${subdomain}/logs`}>
<TextLink href={`/orgs/${orgSlug}/projects/${subdomain}/logs`}>
Logs page
</LogsLink>{' '}
and select the service &quot;Backup Job&quot; to see the restore logs.
</TextLink>{' '}
and select the service &quot;Backup Jobs&quot; to see the restore logs.
</p>
<DialogFooter>
<Button type="button" onClick={onClose}>

View File

@@ -8,7 +8,7 @@ import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
import { render, screen, waitFor } from '@/tests/orgs/testUtils';
import userEvent from '@testing-library/user-event';
import { setupServer } from 'msw/node';
import { vi } from 'vitest';
import { test, vi } from 'vitest';
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
@@ -64,37 +64,62 @@ describe('PointInTimeBackupInfo', () => {
server.listen();
});
afterAll(() => {
server.close();
afterEach(() => {
server.resetHandlers();
vi.restoreAllMocks();
});
test('will fetch the earliest backup and will display the date in with timezone', async () => {
server.use(getOrganization);
afterAll(() => {
server.close();
});
test("will display the earliest backup's date with the local timezone", async () => {
server.use(getProjectQuery);
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
// '10 March 2025, 05:00:05 (UTC+02:00)'
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
expect(earliestBackup).toHaveTextContent(
'10 Mar 2025, 05:00:05 (UTC+02:00)',
);
});
test('will update the date after the timezone is changed', async () => {
test("that the system fetches the earliest backup, displays 'Project has no backups yet' message when no backups exist, and verifies that the restore button is disabled.", async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchEmptyPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const earliestBackup = await screen.getByText(
'Project has no backups yet.',
);
expect(earliestBackup).toBeInTheDocument();
const startRestoreButton = await screen.getByRole('button', {
name: 'Start restore',
});
expect(startRestoreButton).toBeDisabled();
});
test('will update the date after the timezone has changed', async () => {
server.use(getOrganization);
server.use(getProjectQuery);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
const user = userEvent.setup();
// '10 March 2025, 05:00:05 (UTC+02:00)'
const earliestBackup = await screen.getByTestId('EarliestBackupDateTime');
expect(earliestBackup).toHaveTextContent(
'10 Mar 2025, 05:00:05 (UTC+02:00)',
@@ -116,29 +141,8 @@ describe('PointInTimeBackupInfo', () => {
);
});
test('will fetch the earliest backup and display "Project has no backups yet." test if there are now backups and start restore is disabled', async () => {
server.use(getOrganization);
server.use(getProjectQuery);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchEmptyPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() => render(<PointInTimeBackupInfo appId="randomId" />));
// '10 March 2025, 05:00:05 (UTC+02:00)'
const earliestBackup = await screen.getByText(
'Project has no backups yet.',
);
expect(earliestBackup).toBeInTheDocument();
const startRestoreButton = await screen.getByRole('button', {
name: 'Start restore',
});
expect(startRestoreButton).toBeDisabled();
});
test('will schedule a restore', async () => {
server.use(getOrganization);
server.use(getProjectQuery);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
@@ -167,11 +171,8 @@ describe('PointInTimeBackupInfo', () => {
await screen.getByRole('button', { name: 'Select' }),
).toBeInTheDocument(),
);
await user.click(
await screen.getByRole('gridcell', {
name: /13/i,
}),
);
await user.click(await screen.getByText('13'));
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '18');
@@ -236,5 +237,121 @@ describe('PointInTimeBackupInfo', () => {
expect(
mocks.restoreApplicationDatabase.mock.calls[0][0].recoveryTarget,
).toBe('2025-03-13T16:00:05.000Z');
// call the onCompleted cb
await waitFor(() => mocks.restoreApplicationDatabase.mock.calls[0][1]());
expect(
screen.getByText('Backup has been scheduled successfully.'),
).toBeInTheDocument();
});
test('that dates before the earliest backup cannot be selected', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
const user = userEvent.setup();
const startRestoreButton = await screen.getByRole('button', {
name: 'Start restore',
});
await user.click(startRestoreButton);
await waitFor(async () =>
expect(
await screen.getByText('Recover your database from a backup'),
).toBeInTheDocument(),
);
const dateTimePickerButton = await screen.getByRole('button', {
name: /UTC/i,
});
await user.click(dateTimePickerButton);
await waitFor(async () =>
expect(
await screen.getByRole('button', { name: 'Select' }),
).toBeInTheDocument(),
);
expect(await screen.getByText('March 2025')).toBeInTheDocument();
expect(await screen.getByText('9')).toBeDisabled();
expect(await screen.getAllByText('1')[0]).toBeDisabled();
expect(await screen.getAllByText('5')[0]).toBeDisabled();
expect(await screen.getByText('10')).not.toBeDisabled();
expect(await screen.getByText('15')).not.toBeDisabled();
const hoursInput = await screen.getByLabelText('Hours');
await user.type(hoursInput, '{ArrowDown}');
expect(await screen.getByLabelText('Hours')).toHaveValue('04');
const updatedDateTimeButton = await screen.getByRole('button', {
name: /UTC/i,
});
expect(updatedDateTimeButton).toHaveTextContent(
'10 Mar 2025, 04:00:05 (UTC+02:00)',
);
expect(
await screen.queryByRole('button', { name: 'Select' }),
).toBeDisabled();
expect(
await screen.queryByText(
'Selected date and time is before the earliest available backup',
),
).toBeInTheDocument();
expect(
await screen.getByRole('button', {
name: /UTC/i,
}),
).toHaveClass('border-destructive');
});
test('Learn more link is displayed in the "footer" and aligned to the left and the "Start restore" button is to the right', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} showLink />),
);
const learMoreAboutPiTRLink = screen.getByText(
/Learn more about Point-in-Time Recover/,
);
expect(learMoreAboutPiTRLink).toBeInTheDocument();
const linkWrapper = learMoreAboutPiTRLink.closest('div');
expect(linkWrapper).toHaveClass('justify-between');
expect(linkWrapper).not.toHaveClass('justify-end');
});
test('Learn more link is in not displayed in the "footer" the "Start restore" button is aligned to the right', async () => {
server.use(getOrganization);
mocks.useGetPiTrBaseBackupsLazyQuery.mockImplementation(() => [
fetchPiTRBaseBackups,
{ loading: false },
]);
await waitFor(() =>
render(<PointInTimeBackupInfo appId={mockApplication.id} />),
);
expect(
screen.queryByText(/Learn more about Point-in-Time Recover/),
).not.toBeInTheDocument();
const startRestoreButton = screen.getByText('Start restore');
const linkWrapper = startRestoreButton.closest('div');
expect(linkWrapper).not.toHaveClass('justify-between');
expect(linkWrapper).toHaveClass('justify-end');
});
});

View File

@@ -1,15 +1,31 @@
import usePiTRBaseBackups from '@/features/orgs/hooks/usePiTRBaseBackups/usePiTRBaseBackups';
import { isEmptyValue } from '@/lib/utils';
import { Info } from 'lucide-react';
import { cn, isEmptyValue } from '@/lib/utils';
import { Info, SquareArrowUpRightIcon } from 'lucide-react';
import Link from 'next/link';
import EarliestBackup from './EarliestBackup';
import RestoreBackupDialogButton from './RestoreBackupDialogButton';
function LearnMoreAboutPiTRLink() {
return (
<Link
href="https://docs.nhost.io/guides/database/backups#point-in-time-recovery"
className="flex items-center gap-1 text-[0.9375rem] leading-[1.375rem] text-[#0052cd] hover:underline dark:text-[#3888ff]"
target="_blank"
rel="noopener noreferrer"
>
Learn more about Point-in-Time Recovery{' '}
<SquareArrowUpRightIcon className="h-4 w-4" />
</Link>
);
}
interface Props {
appId: string;
title?: string;
dialogTitle?: string;
dialogButtonText?: string;
dialogTriggerText?: string;
showLink?: boolean;
}
function PointInTimeBackupInfo({
@@ -18,12 +34,13 @@ function PointInTimeBackupInfo({
dialogTitle = 'Recover your database from a backup',
dialogButtonText,
dialogTriggerText,
showLink = false,
}: Props) {
const { earliestBackupDate, loading } = usePiTRBaseBackups(appId);
const disableStartRestoreButton = loading || isEmptyValue(earliestBackupDate);
return (
/* Move this part to a different component */
<div className="rounded-lg border border-[#EAEDF0] dark:border-[#2F363D]">
<div className="flex w-full flex-col items-start gap-6 p-4">
<h3 className="leading-[1.375] text-[0.9375]">
@@ -46,7 +63,13 @@ function PointInTimeBackupInfo({
</div>
</div>
</div>
<div className="flex w-full items-center justify-end border-t border-[#EAEDF0] p-4 dark:border-[#2F363D]">
<div
className={cn(
'flex w-full items-center border-t border-[#EAEDF0] p-4 dark:border-[#2F363D]',
{ 'justify-between': showLink, 'justify-end': !showLink },
)}
>
{showLink && <LearnMoreAboutPiTRLink />}
<RestoreBackupDialogButton
disabled={disableStartRestoreButton}
earliestBackupDate={earliestBackupDate}

View File

@@ -1,4 +1,5 @@
import { DateTimePicker } from '@/components/common/DateTimePicker';
import { isTZDate } from '@/components/common/TimePicker/time-picker-utils';
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
import {
Dialog,
@@ -12,7 +13,7 @@ import {
import { useRestoreApplicationDatabasePiTR } from '@/features/orgs/hooks/useRestoreApplicationDatabasePiTR';
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import type { TZDate } from '@date-fns/tz';
import { TZDate } from '@date-fns/tz';
import { DialogDescription } from '@radix-ui/react-dialog';
import { format, isBefore, startOfDay } from 'date-fns-v4';
import { memo, useCallback, useEffect, useState } from 'react';
@@ -77,8 +78,21 @@ function RestoreBackupDialogButton({
return format(date, 'dd MMM yyyy, HH:mm:ss (OOOO)').replace('GMT', 'UTC');
}
function isCalendarDayDisabled(date: Date) {
return isBefore(startOfDay(date), startOfDay(earliestBackupDate));
function isCalendarDayDisabled(date: Date | TZDate) {
if (isTZDate(date)) {
const utcDay = new Date(date.getTime()).toISOString();
const tzDate = new TZDate(utcDay, date.timeZone);
const earliestBackupDateInTz = new TZDate(
earliestBackupDate,
date.timeZone,
);
return isBefore(startOfDay(tzDate), startOfDay(earliestBackupDateInTz));
}
return isBefore(
startOfDay(new Date(date.getTime()).toISOString()),
startOfDay(earliestBackupDate),
);
}
const resetState = useCallback(() => {

View File

@@ -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;

View File

@@ -198,24 +198,33 @@ function ColumnAutocomplete(
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<PopoverTrigger
asChild
title={
buttonPrefix
? `${buttonPrefix}.${selectedColumn?.label}`
: selectedColumn?.label || 'Select a column'
}
>
<Button
ref={ref}
disabled={disabled}
variant="outline"
role="combobox"
aria-expanded={open}
className="justify-between"
className="w-full justify-between"
>
{buttonPrefix ? (
<div className="flex flex-shrink-0 gap-0 truncate">
<span className="flex-shrink-0 truncate text-sm text-muted-foreground lg:max-w-[200px]">
<div className="flex min-w-0 flex-shrink items-center gap-0">
<span className="flex-shrink truncate text-sm text-muted-foreground lg:max-w-[200px]">
{buttonPrefix}.
</span>
{selectedColumn?.label}
<span className="truncate">{selectedColumn?.label}</span>
</div>
) : (
selectedColumn?.label || 'Select a column'
<span className="truncate">
{selectedColumn?.label || 'Select a column'}
</span>
)}
<ChevronsUpDown className="ml-2 h-5 w-5 shrink-0 opacity-50" />
</Button>
@@ -244,10 +253,11 @@ function ColumnAutocomplete(
placeholder=""
prefix={
relationshipDotNotation
? `
${selectedTable}.${relationshipDotNotation}.`
: ``
? `${selectedTable}.${relationshipDotNotation}.`
: ''
}
className="w-auto min-w-0 flex-grow items-center gap-0 pl-0"
prefixClassName="flex-shrink truncate max-w-[200px]"
/>
{pages?.length > 0 ? (
<div className="flex flex-row items-center gap-2 px-2 py-1.5">
@@ -286,7 +296,10 @@ function ColumnAutocomplete(
)}
/>
<div className="flex gap-3">
<span className="line-clamp-2 break-all">
<span
title={option.label}
className="line-clamp-2 break-all"
>
{option.label}
</span>
<div className="flex items-center">
@@ -330,7 +343,10 @@ function ColumnAutocomplete(
)}
/>
<div className="flex gap-3">
<span className="line-clamp-2 break-all">
<span
title={option.label}
className="line-clamp-2 break-all"
>
{option.label}
</span>
<div className="flex items-center">

View File

@@ -1,6 +1,8 @@
import { SettingsContainer } from '@/components/layout/SettingsContainer';
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
import { useDatabasePiTRSettings } from '@/features/orgs/hooks/useDatabasePiTRSettings/';
import { useUpdateDatabasePiTRConfig } from '@/features/orgs/hooks/useUpdateDatabasePiTRConfig';
import TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
import { UpgradeNotification } from '@/features/orgs/projects/database/settings/components/UpgradeNotification';
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
import { isEmptyValue } from '@/lib/utils';
@@ -29,8 +31,8 @@ export default function DatabasePiTRSettings() {
return (
<SettingsContainer
title="Point-in-time recovery"
description="Enable Point-in-Time recovery (PiTR). Available as an add-on for organizations on Pro, Team, or Enterprise plans."
title="Point-in-Time Recovery"
description="Enable Point-in-Time Recovery (PiTR)."
slotProps={{
submitButton: {
disabled: isSwitchDisabled,
@@ -43,11 +45,19 @@ export default function DatabasePiTRSettings() {
showSwitch={shouldShowSwitch}
enabled={isPiTREnabled}
onEnabledChange={handleEnabledChange}
docsLink="https://docs.nhost.io/product/database#point-in-time-recovery"
docsLink="https://docs.nhost.io/guides/database/backups#point-in-time-recovery"
docsTitle="enabling or disabling PiTR"
>
{isFreeProject && (
{isFreeProject ? (
<UpgradeNotification description="To unlock this add-on, transfer this project to a Pro or Team organization." />
) : (
<InfoAlert borderLess>
Available as an add-on for organizations on Pro, Team, or Enterprise
plans for <strong>$100 per month.</strong>{' '}
<TextLink href="https://nhost.io/pricing">
View pricing details
</TextLink>
</InfoAlert>
)}
</SettingsContainer>
);

View File

@@ -49,7 +49,7 @@ export const mockApplication: Project = {
name: 'Test Application',
slug: 'test-application',
appStates: [],
subdomain: '',
subdomain: 'subdomain',
region: {
name: 'us-east-1',
city: 'New York',

View 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":}

View 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: [],
}),
),
);

View File

@@ -23,18 +23,17 @@ declare namespace Intl {
// eslint-disable-next-line vars-on-top, no-var
var DateTimeFormat: DateTimeFormat;
}
// Common
export const UTC_GMT_TIMEZONE = {
label: 'UTC, GMT (UTC+00:00)',
value: 'UTC',
key: 'UTC',
};
// Common
// Common
export function guessTimezone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Common
export function getUTCOffsetInHours(
timezone: string,
dateTime: string,
@@ -43,7 +42,7 @@ export function getUTCOffsetInHours(
const date = new TZDate(dateTime, timezone);
return format(date, dateFormat).replace('GMT', 'UTC');
}
// Common
export function createTimezoneOptions(dateTime: string) {
const validTimezones = new Set(Intl.supportedValuesOf('timeZone'));

View File

@@ -6,6 +6,7 @@ export default defineConfig({
// @ts-ignore
plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] }), react()],
test: {
globalSetup: './vitest.global-setup.ts',
testTimeout: 30000,
environment: 'jsdom',
globals: true,

View File

@@ -0,0 +1,3 @@
export const setup = () => {
process.env.TZ = 'Europe/Helsinki';
};

View File

@@ -24,7 +24,7 @@
"@nhost/react": "workspace:^",
"@nhost/react-apollo": "workspace:^",
"graphql": "16.8.1",
"next": "^14.2.22",
"next": "^14.2.25",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-icons": "^4.12.0"

View File

@@ -18,7 +18,7 @@
"form-data": "^4.0.0",
"graphql": "16.8.1",
"js-cookie": "^3.0.5",
"next": "^14.2.22",
"next": "^14.2.25",
"postcss": "^8.4.38",
"react": "^18.2.0",
"react-dom": "^18.2.0",

View File

@@ -15,6 +15,7 @@
"devDependencies": {
"@playwright/test": "^1.41.0",
"@sveltejs/adapter-auto": "^3.3.1",
"@sveltejs/adapter-vercel": "^5.6.3",
"@sveltejs/kit": "^2.11.1",
"@sveltejs/vite-plugin-svelte": "^5.0.2",
"@types/js-cookie": "^3.0.6",

View File

@@ -82,6 +82,8 @@
"husky": "^8.0.3",
"npm-run-all": "^4.1.5",
"prettier": "^3.3.3",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",
"turbo": "2.3.3",
"typedoc": "^0.22.18",
"typescript": "4.9.5",

View File

@@ -78,7 +78,7 @@
"devDependencies": {
"@nhost/docgen": "workspace:*",
"@types/js-cookie": "^3.0.6",
"next": "^14.2.22",
"next": "^14.2.25",
"react": "^18.2.0",
"react-dom": "^18.2.0"
}

478
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
"@craco/craco": "^7.1.0",
"@hookform/resolvers": "^3.9.0",
"@icons-pack/react-simple-icons": "^9.6.0",
"@nhost/react": "^3.10.1",
"@nhost/react": "^3.10.2",
"@nhost/react-apollo": "^16.0.1",
"@radix-ui/react-dialog": "^1.1.1",
"@radix-ui/react-dropdown-menu": "^2.1.1",

View File

@@ -13,7 +13,7 @@
"@nhost-examples/sveltekit#build": {
"dependsOn": ["^build"],
"outputs": [".svelte-kit/**", ".vercel/**"],
"env": ["PUBLIC_NHOST_SUBDOMAIN", "PUBLIC_NHOST_REGION"]
"env": ["PUBLIC_NHOST_SUBDOMAIN", "PUBLIC_NHOST_REGION", "ENABLE_EXPERIMENTAL_COREPACK"]
},
"@nhost-examples/vue-apollo#build": {
"dependsOn": ["^build"],