Compare commits
23 Commits
feat/add-e
...
@nhost/vue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be8cd6c3a6 | ||
|
|
b89500d175 | ||
|
|
013e1c1d70 | ||
|
|
4fd176bce2 | ||
|
|
d26b6b848d | ||
|
|
3df7ca2a33 | ||
|
|
38e7e9deee | ||
|
|
25f07a3763 | ||
|
|
38c3db4a9e | ||
|
|
a1333df2a1 | ||
|
|
0420e4fda4 | ||
|
|
97f6642c43 | ||
|
|
69c1ffa766 | ||
|
|
8ea263ec75 | ||
|
|
7b9cdf1f5f | ||
|
|
1c4f321f64 | ||
|
|
60d4d28627 | ||
|
|
34fdcb8863 | ||
|
|
78436ca29e | ||
|
|
ea6584614b | ||
|
|
4937c5e055 | ||
|
|
b5a3895e16 | ||
|
|
9b24807562 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
'@nhost/dashboard': minor
|
||||
---
|
||||
|
||||
fix: update babel dependencies to address security audit vulnerabilities
|
||||
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -19,7 +19,6 @@ env:
|
||||
NEXT_PUBLIC_ENV: dev
|
||||
NEXT_TELEMETRY_DISABLED: 1
|
||||
NHOST_TEST_DASHBOARD_URL: ${{ vars.NHOST_TEST_DASHBOARD_URL }}
|
||||
NHOST_TEST_WORKSPACE_NAME: ${{ vars.NHOST_TEST_WORKSPACE_NAME }}
|
||||
NHOST_TEST_PROJECT_NAME: ${{ vars.NHOST_TEST_PROJECT_NAME }}
|
||||
NHOST_TEST_ORGANIZATION_NAME: ${{ vars.NHOST_TEST_ORGANIZATION_NAME }}
|
||||
NHOST_TEST_ORGANIZATION_SLUG: ${{ vars.NHOST_TEST_ORGANIZATION_SLUG }}
|
||||
|
||||
@@ -31,7 +31,7 @@ PRs to our libraries are always welcome and can be a quick way to get your fix o
|
||||
- Only fix/add the functionality in question **OR** address wide-spread whitespace/style issues, not both.
|
||||
- Add unit or integration tests for fixed or changed functionality (if a test suite exists).
|
||||
- Address a single concern in the least number of changed lines as possible.
|
||||
- Include documentation in the repo or on our [docs site](https://docs.nhost.io/get-started).
|
||||
- Include documentation in the repo or on our [docs site](https://docs.nhost.io).
|
||||
- Be accompanied by a complete Pull Request template (loaded automatically when a PR is created).
|
||||
|
||||
For changes that address core functionality or require breaking changes (e.g., a major release), it's best to open an Issue to discuss your proposal first. This is not required but can save time creating and reviewing changes.
|
||||
|
||||
@@ -14,10 +14,10 @@ The easiest way to install `pnpm` if it's not installed on your machine yet is t
|
||||
$ npm install -g pnpm
|
||||
```
|
||||
|
||||
### [Nhost CLI](https://docs.nhost.io/cli)
|
||||
### [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
|
||||
|
||||
- The CLI is primarily used for running the E2E tests
|
||||
- Please refer to the [installation guide](https://docs.nhost.io/get-started/cli-workflow/install-cli) if you have not installed it yet
|
||||
- Please refer to the [installation guide](https://docs.nhost.io/platform/cli/local-development) if you have not installed it yet
|
||||
|
||||
## File Structure
|
||||
|
||||
|
||||
28
README.md
28
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
# Nhost
|
||||
|
||||
<a href="https://docs.nhost.io/introduction#quick-start-guides">Quickstart</a>
|
||||
<a href="https://docs.nhost.io/getting-started/overview">Quickstart</a>
|
||||
<span> • </span>
|
||||
<a href="http://nhost.io/">Website</a>
|
||||
<span> • </span>
|
||||
@@ -36,7 +36,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- Serverless Functions: Node.js (JavaScript and TypeScript)
|
||||
- [Nhost CLI](https://docs.nhost.io/development/cli/overview) for local development
|
||||
- [Nhost CLI](https://docs.nhost.io/platform/cli/local-development) for local development
|
||||
|
||||
## Architecture of Nhost
|
||||
|
||||
@@ -89,25 +89,25 @@ await nhost.graphql.request(`{
|
||||
Nhost is frontend agnostic, which means Nhost works with all frontend frameworks.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/nuxtjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/react"><img src="assets/react.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/react-native.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/svelte.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/getting-started/quickstart/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript/nhost-js/nhost-client"><img src="assets/nuxtjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/getting-started/quickstart/react"><img src="assets/react.svg"/></a>
|
||||
<a href="https://docs.nhost.io/getting-started/quickstart/reactnative"><img src="assets/react-native.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript/nhost-js/nhost-client"><img src="assets/svelte.svg"/></a>
|
||||
<a href="https://docs.nhost.io/getting-started/quickstart/vue"><img src="assets/vuejs.svg"/></a>
|
||||
</div>
|
||||
|
||||
# Resources
|
||||
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/cli)
|
||||
- Start developing locally with the [Nhost CLI](https://docs.nhost.io/platform/cli/local-development)
|
||||
|
||||
## Nhost Clients
|
||||
|
||||
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript)
|
||||
- [JavaScript/TypeScript](https://docs.nhost.io/reference/javascript/nhost-js/nhost-client)
|
||||
- [Dart and Flutter](https://github.com/nhost/nhost-dart)
|
||||
- [React](https://docs.nhost.io/reference/react)
|
||||
- [Next.js](https://docs.nhost.io/reference/nextjs)
|
||||
- [Vue](https://docs.nhost.io/reference/vue)
|
||||
- [React](https://docs.nhost.io/reference/react/nhost-client)
|
||||
- [Next.js](https://docs.nhost.io/reference/nextjs/nhost-client)
|
||||
- [Vue](https://docs.nhost.io/reference/vue/nhost-client)
|
||||
|
||||
## Integrations
|
||||
|
||||
@@ -140,7 +140,7 @@ This repository, and most of our other open source projects, are licensed under
|
||||
|
||||
Here are some ways of contributing to making Nhost better:
|
||||
|
||||
- **[Try out Nhost](https://docs.nhost.io/introduction)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- **[Try out Nhost](https://docs.nhost.io)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
|
||||
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!
|
||||
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 013e1c1: fix: update vite and image-size dependencies to address security audit vulnerabilities
|
||||
- 4fd176b: chore: re-add user event ci tests, updated sveltekit example tests to e2e suite
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- a1333df: fix: update vite because of vulnerability
|
||||
- 0420e4f: fix (dashboard): Display the selected date's month in the datetime picker component
|
||||
- @nhost/react-apollo@17.0.3
|
||||
- @nhost/nextjs@2.2.6
|
||||
|
||||
## 2.26.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 7b9cdf1: chore: remove legacy workspaces
|
||||
- 1c4f321: fix: update vite to fix audit vulnerabilities
|
||||
|
||||
## 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
|
||||
|
||||
@@ -38,7 +38,7 @@ These files are added to `.gitignore`, so you don't need to worry about committi
|
||||
|
||||
### Enable Local Development
|
||||
|
||||
You can connect the Nhost Dashboard to your **locally running** Nhost backend in a few steps. Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli#installation).
|
||||
You can connect the Nhost Dashboard to your **locally running** Nhost backend in a few steps. Make sure you have the [Nhost CLI installed](https://docs.nhost.io/platform/cli/local-development).
|
||||
|
||||
First, you need to run the following command to start your backend locally:
|
||||
|
||||
@@ -149,8 +149,11 @@ Next, you need to create a project. Create a `.env.test` file with the following
|
||||
NHOST_TEST_DASHBOARD_URL=<test_dashboard_url>
|
||||
NHOST_TEST_USER_EMAIL=<test_user_email>
|
||||
NHOST_TEST_USER_PASSWORD=<test_user_password>
|
||||
NHOST_TEST_WORKSPACE_NAME=<test_workspace_name>
|
||||
NHOST_TEST_ORGANIZATION_NAME=<test_organization_name>
|
||||
NHOST_TEST_ORGANIZATION_SLUG=<test_organization_slug>
|
||||
NHOST_TEST_PERSONAL_ORG_SLUG=<test_personal_org_slug>
|
||||
NHOST_TEST_PROJECT_NAME=<test_project_name>
|
||||
NHOST_TEST_PROJECT_SUBDOMAIN=<test_project_subdomain>
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
|
||||
```
|
||||
|
||||
@@ -159,11 +162,14 @@ NHOST_TEST_PROJECT_ADMIN_SECRET=<test_project_admin_secret>
|
||||
- `NHOST_TEST_DASHBOARD_URL`: The URL to run the tests against (e.g: http://localhost:3000 or https://staging.app.nhost.io)
|
||||
- `NHOST_TEST_USER_EMAIL`: Email address of the test user that owns the test project
|
||||
- `NHOST_TEST_USER_PASSWORD`: Password of the test user that owns the test project
|
||||
- `NHOST_TEST_WORKSPACE_NAME`: Name of the workspace that contains the test project
|
||||
- `NHOST_TEST_ORGANIZATION_NAME`: Name of the organization that contains the test project
|
||||
- `NHOST_TEST_ORGANIZATION_SLUG`: Slug of the organization that contains the test project
|
||||
- `NHOST_TEST_PERSONAL_ORG_SLUG`: Slug of the personal organization that contains the test project
|
||||
- `NHOST_TEST_PROJECT_NAME`: Name of the test project
|
||||
- `NHOST_TEST_PROJECT_SUBDOMAIN`: Subdomain of the test project
|
||||
- `NHOST_TEST_PROJECT_ADMIN_SECRET`: Admin secret of the test project
|
||||
|
||||
Make sure to copy the workspace and project information from the [Nhost Dashboard](https://app.nhost.io/).
|
||||
Make sure to copy the organization and project information from the [Nhost Dashboard](https://app.nhost.io/).
|
||||
|
||||
End-to-end tests are written using [Playwright](https://playwright.dev/). To run the tests, run the following command:
|
||||
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to create then delete a personal access token', async () => {
|
||||
test('should be able to create then delete a personal access token', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.waitForTimeout(1000);
|
||||
await page.getByRole('banner').getByRole('button').last().click();
|
||||
await page.getByRole('link', { name: /account settings/i }).click();
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
@@ -23,11 +14,9 @@ test.beforeEach(async () => {
|
||||
await page.waitForURL(AIRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete an Assistant', async () => {
|
||||
test('should create and delete an Assistant', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('link', { name: 'Assistants' }).click();
|
||||
|
||||
await expect(page.getByText(/no assistants are configured/i)).toBeVisible();
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
@@ -23,11 +15,9 @@ test.beforeEach(async () => {
|
||||
await page.waitForURL(AIRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete an Auto-Embeddings', async () => {
|
||||
test('should create and delete an Auto-Embeddings', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Add a new Auto-Embeddings' }).click();
|
||||
|
||||
await page.getByLabel('Name').fill('test');
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
test('should be able to ban and unban a user', async ({ page }) => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test('should be able to ban and unban a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,12 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a user', async () => {
|
||||
test('should create a user', async ({ authenticatedNhostPage: page }) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -31,7 +17,9 @@ test('should create a user', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to create a user with an existing email', async () => {
|
||||
test('should not be able to create a user with an existing email', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,15 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to delete a user', async () => {
|
||||
test('should be able to delete a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -52,7 +41,9 @@ test('should be able to delete a user', async () => {
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should be able to delete a user from the details page', async () => {
|
||||
test('should be able to delete a user from the details page', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import test, { expect } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to edit user roles from the details page', async () => {
|
||||
test('should be able to edit user roles from the details page', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
|
||||
@@ -1,26 +1,14 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { createUser, generateTestEmail } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { createUser, generateTestEmail, gotoAuthURL } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await gotoAuthURL(page);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should be able to verify the email of a user', async () => {
|
||||
test('should be able to verify the email of a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
|
||||
@@ -50,7 +38,9 @@ test('should be able to verify the email of a user', async () => {
|
||||
).toBeChecked();
|
||||
});
|
||||
|
||||
test('should be able to verify the phone number of a user', async () => {
|
||||
test('should be able to verify the phone number of a user', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const email = generateTestEmail();
|
||||
const password = faker.internet.password();
|
||||
const phoneNumber = faker.phone.number();
|
||||
|
||||
@@ -1,35 +1,18 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { navigateToProject, prepareTable } from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a simple table', async () => {
|
||||
test('should create a simple table', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -57,7 +40,9 @@ test('should create a simple table', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with unique constraints', async () => {
|
||||
test('should create a table with unique constraints', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -86,7 +71,9 @@ test('should create a table with unique constraints', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with nullable columns', async () => {
|
||||
test('should create a table with nullable columns', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -115,7 +102,9 @@ test('should create a table with nullable columns', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with an identity column', async () => {
|
||||
test('should create a table with an identity column', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -148,7 +137,9 @@ test('should create a table with an identity column', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create table with foreign key constraint', async () => {
|
||||
test('should create table with foreign key constraint', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -221,7 +212,9 @@ test('should create table with foreign key constraint', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to create a table with a name that already exists', async () => {
|
||||
test('should not be able to create a table with a name that already exists', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
|
||||
@@ -1,35 +1,17 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { deleteTable, navigateToProject, prepareTable } from '@/e2e/utils';
|
||||
import { deleteTable, prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should delete a table', async () => {
|
||||
test('should delete a table', async ({ authenticatedNhostPage: page }) => {
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
@@ -65,7 +47,9 @@ test('should delete a table', async () => {
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async () => {
|
||||
test('should not be able to delete a table if other tables have foreign keys referencing it', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
test.setTimeout(60000);
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -1,39 +1,18 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import {
|
||||
clickPermissionButton,
|
||||
navigateToProject,
|
||||
prepareTable,
|
||||
} from '@/e2e/utils';
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { clickPermissionButton, prepareTable } from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { snakeCase } from 'snake-case';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions to select row', async () => {
|
||||
test('should create a table with role permissions to select row', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
@@ -79,7 +58,9 @@ test('should create a table with role permissions to select row', async () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions and a custom check to select rows', async () => {
|
||||
test('should create a table with role permissions and a custom check to select rows', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
export const TEST_DASHBOARD_URL = process.env.NHOST_TEST_DASHBOARD_URL;
|
||||
|
||||
/**
|
||||
* Name of the workspace to test against.
|
||||
* Name of the organization to test against.
|
||||
*/
|
||||
export const TEST_ORGANIZATION_NAME = process.env.NHOST_TEST_ORGANIZATION_NAME;
|
||||
|
||||
|
||||
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
22
dashboard/e2e/fixtures/auth-hook.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { TEST_DASHBOARD_URL, TEST_PERSONAL_ORG_SLUG } from '@/e2e/env';
|
||||
import { type Page, test as base } from '@playwright/test';
|
||||
|
||||
export const AUTH_CONTEXT = 'e2e/.auth/user.json';
|
||||
|
||||
export const test = base.extend<{ authenticatedNhostPage: Page }>({
|
||||
authenticatedNhostPage: async ({ browser }, use) => {
|
||||
const context = await browser.newContext({ storageState: AUTH_CONTEXT });
|
||||
const page = await context.newPage();
|
||||
await page.goto('/');
|
||||
await page.waitForURL(
|
||||
`${TEST_DASHBOARD_URL}/orgs/${TEST_PERSONAL_ORG_SLUG}/projects`,
|
||||
{ waitUntil: 'networkidle' },
|
||||
);
|
||||
await use(page);
|
||||
// update the context to get the new refresh token
|
||||
await page.context().storageState({ path: AUTH_CONTEXT });
|
||||
await page.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
@@ -1,15 +1,8 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { navigateToProject } from '../utils';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
|
||||
await page.goto('/');
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
@@ -17,11 +10,9 @@ test.beforeAll(async ({ browser }) => {
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should show the navtree with all links visible', async () => {
|
||||
test('should show the navtree with all links visible', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
const navLocator = page.getByLabel('Navigation Tree');
|
||||
await expect(navLocator).toBeVisible();
|
||||
|
||||
@@ -42,16 +33,20 @@ test('should show the navtree with all links visible', async () => {
|
||||
'Settings',
|
||||
];
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const linkName of links) {
|
||||
const link =
|
||||
linkName === 'Settings'
|
||||
? page.getByRole('link', { name: linkName }).first()
|
||||
: page.getByRole('link', { name: linkName });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await expect(link).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test("should show the project's region and subdomain", async () => {
|
||||
test("should show the project's region and subdomain", async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await expect(page.locator('p:has-text("Region") + div p').nth(0)).toHaveText(
|
||||
/frankfurt \(eu-central-1\)/i,
|
||||
);
|
||||
@@ -60,7 +55,9 @@ test("should show the project's region and subdomain", async () => {
|
||||
).toHaveText(/[a-z]{20}/i);
|
||||
});
|
||||
|
||||
test('should not have a GitHub repository connected', async () => {
|
||||
test('should not have a GitHub repository connected', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await expect(
|
||||
page.getByRole('button', { name: /connect to github/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -1,33 +1,15 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { navigateToProject } from '../utils';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
test.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const runRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/run`;
|
||||
await page.goto(runRoute);
|
||||
await page.waitForURL(runRoute);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
|
||||
test('should create and delete a run service', async () => {
|
||||
test('should create and delete a run service', async ({
|
||||
authenticatedNhostPage: page,
|
||||
}) => {
|
||||
await page.getByRole('button', { name: 'Add service' }).first().click();
|
||||
await expect(page.getByText(/create a new service/i)).toBeVisible();
|
||||
await page.getByPlaceholder(/service name/i).click();
|
||||
|
||||
@@ -1,49 +1,23 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
} from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import { type Page, expect, test as teardown } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
teardown.beforeAll(async ({ browser }) => {
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
});
|
||||
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
teardown.beforeEach(async () => {
|
||||
await page.goto('/');
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { expect, test as teardown } from '@/e2e/fixtures/auth-hook';
|
||||
|
||||
teardown.beforeEach(async ({ authenticatedNhostPage: page }) => {
|
||||
const databaseRoute = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default`;
|
||||
await page.goto(databaseRoute);
|
||||
await page.waitForURL(databaseRoute);
|
||||
});
|
||||
|
||||
teardown.afterAll(async () => {
|
||||
await page.close();
|
||||
});
|
||||
teardown(
|
||||
'clean up database tables',
|
||||
async ({ authenticatedNhostPage: page }) => {
|
||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||
|
||||
teardown('clean up database tables', async () => {
|
||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||
);
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||
);
|
||||
|
||||
const inputField = page.locator('[contenteditable]');
|
||||
await inputField.fill(`
|
||||
const inputField = page.locator('[contenteditable]');
|
||||
await inputField.fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
@@ -56,6 +30,7 @@ teardown('clean up database tables', async () => {
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||
await expect(page.getByText(/success/i)).toBeVisible();
|
||||
});
|
||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||
await expect(page.getByText(/success/i)).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
@@ -211,3 +212,9 @@ export async function clickPermissionButton({
|
||||
.locator('button')
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function gotoAuthURL(page) {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.24.0",
|
||||
"version": "2.27.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",
|
||||
@@ -112,7 +112,6 @@
|
||||
"recoil-persist": "^5.1.0",
|
||||
"rehype-highlight": "^7.0.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"shell-quote": "^1.8.1",
|
||||
"slugify": "^1.6.6",
|
||||
"stripe": "^10.17.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
@@ -157,7 +156,6 @@
|
||||
"@types/react": "^18.2.73",
|
||||
"@types/react-dom": "^18.2.23",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/testing-library__jest-dom": "^5.14.9",
|
||||
"@types/uuid": "^9.0.8",
|
||||
"@types/validator": "^13.11.9",
|
||||
@@ -196,7 +194,7 @@
|
||||
"tailwindcss": "^3.4.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.4.12",
|
||||
"vite": "^5.4.17",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ContactUsProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement> {
|
||||
isTeam?: boolean;
|
||||
isOwner?: boolean;
|
||||
}
|
||||
|
||||
export default function FeedbackForm({
|
||||
className,
|
||||
isTeam,
|
||||
isOwner,
|
||||
...props
|
||||
}: ContactUsProps) {
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'grid max-w-md grid-flow-row gap-2 px-5 py-4',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text variant="h3" component="h2">
|
||||
Contact us
|
||||
</Text>
|
||||
|
||||
{isTeam && isOwner && (
|
||||
<Text>
|
||||
If this is a new Team project, or you need to manage members, reach
|
||||
out to us on discord or via email at{' '}
|
||||
<Link
|
||||
href="mailto:support@nhost.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
support@nhost.io
|
||||
</Link>{' '}
|
||||
so we can have your dedicated channel set up.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isTeam && !isOwner && (
|
||||
<Text>
|
||||
As part of a team plan you can reach out to us on the private channel
|
||||
for this workspace. If you haven't been added to the channel, ask
|
||||
the workspace owner to add you.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Text>
|
||||
To report issues with Nhost, please open a GitHub issue in the{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
nhost/nhost
|
||||
</Link>{' '}
|
||||
repository.
|
||||
</Text>
|
||||
<Text>
|
||||
For issues related to the CLI, please visit the{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/cli/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
nhost/cli
|
||||
</Link>{' '}
|
||||
repository.
|
||||
</Text>
|
||||
<Text>
|
||||
If you need assistance or have any questions, feel free to join us on{' '}
|
||||
<Link
|
||||
href="https://discord.com/invite/9V7Qb2U"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
Discord
|
||||
</Link>
|
||||
. Alternatively, if you prefer, you can also open a{' '}
|
||||
<Link
|
||||
href="https://github.com/nhost/nhost/discussions/new/choose"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
>
|
||||
GitHub discussion
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Text>We're here to help, so don't hesitate to reach out!</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ContactUs';
|
||||
export { default as ContactUs } from './ContactUs';
|
||||
@@ -0,0 +1,177 @@
|
||||
import { isTZDate } from '@/components/common/TimePicker/time-picker-utils';
|
||||
import { render, screen, TestUserEvent, waitFor } from '@/tests/testUtils';
|
||||
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 = new TestUserEvent();
|
||||
|
||||
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 = new TestUserEvent();
|
||||
|
||||
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 = new TestUserEvent();
|
||||
|
||||
await user.click(await screen.findByTestId('dateTimePickerTrigger'));
|
||||
|
||||
expect(await screen.findByText(/Timezone:/)).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByTestId('timezoneSettingsButton'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+02:00',
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Next Month' }),
|
||||
);
|
||||
|
||||
expect(screen.getByText('April 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('18'));
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+03:00',
|
||||
);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Go to the Previous Month' }),
|
||||
);
|
||||
|
||||
expect(await screen.getByText('March 2025')).toBeInTheDocument();
|
||||
|
||||
await user.click(await screen.getByText('21'));
|
||||
|
||||
expect(await screen.findByText(/Timezone: /i)).toHaveTextContent(
|
||||
'Timezone: UTC+02:00',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -27,8 +27,6 @@ export interface DateTimePickerProps {
|
||||
align?: 'start' | 'center' | 'end';
|
||||
validateDateFn?: (date: Date) => string;
|
||||
}
|
||||
// in: UTC datetime
|
||||
// out: UTC dateTime
|
||||
|
||||
function DateTimePicker({
|
||||
dateTime,
|
||||
@@ -49,6 +47,10 @@ function DateTimePicker({
|
||||
});
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const [timezone, setTimezone] = useState(
|
||||
() => defaultTimezone || guessTimezone(),
|
||||
);
|
||||
|
||||
function emitNewDateTime() {
|
||||
onDateTimeChange(new Date(date.getTime()).toISOString());
|
||||
}
|
||||
@@ -73,6 +75,7 @@ function DateTimePicker({
|
||||
|
||||
function handleTimezoneChange(newTimezone: string) {
|
||||
const newDateWithTimezone = new TZDate(date.toISOString(), newTimezone);
|
||||
setTimezone(newTimezone);
|
||||
setDate(newDateWithTimezone);
|
||||
}
|
||||
|
||||
@@ -80,6 +83,7 @@ function DateTimePicker({
|
||||
if (!newOpenState) {
|
||||
if (withTimezone) {
|
||||
const tz = defaultTimezone || guessTimezone();
|
||||
setTimezone(tz);
|
||||
setDate(new TZDate(dateTime, tz));
|
||||
}
|
||||
setDate(parseISO(dateTime));
|
||||
@@ -92,6 +96,8 @@ function DateTimePicker({
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
const selectedDateInUTC = new Date(date.getTime()).toISOString();
|
||||
|
||||
const dateString = formatDateFn?.(date) || format(date, 'PPP HH:mm:ss');
|
||||
|
||||
const errorText = validateDateFn?.(date);
|
||||
@@ -101,6 +107,7 @@ function DateTimePicker({
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
data-testid="dateTimePickerTrigger"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-between text-left font-normal',
|
||||
@@ -113,15 +120,17 @@ function DateTimePicker({
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-auto p-0" align={align}>
|
||||
<div className="flex">
|
||||
<div className="flex">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
defaultMonth={date}
|
||||
onSelect={(d) => handleSelect(d)}
|
||||
initialFocus
|
||||
disabled={isCalendarDayDisabled}
|
||||
timeZone={timezone}
|
||||
/>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div>
|
||||
@@ -131,7 +140,7 @@ function DateTimePicker({
|
||||
{withTimezone && (
|
||||
<div className="border-t border-border p-3">
|
||||
<TimezoneSettings
|
||||
dateTime={dateTime}
|
||||
dateTime={selectedDateInUTC}
|
||||
onTimezoneChange={handleTimezoneChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,16 +18,23 @@ function TimezoneSettings({ dateTime, onTimezoneChange }: Props) {
|
||||
setTimezone(tz.value);
|
||||
onTimezoneChange?.(tz.value);
|
||||
}
|
||||
|
||||
const utcOffset = getUTCOffsetInHours(selectedTimezone, dateTime, 'OOOO');
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
Timezone: {utcOffset}{' '}
|
||||
<span>Timezone: {utcOffset}</span>
|
||||
<TimezonePicker
|
||||
dateTime={dateTime}
|
||||
selectedTimezone={selectedTimezone}
|
||||
onTimezoneSelect={handleTimezoneSelect}
|
||||
button={
|
||||
<Button variant="ghost" size="icon">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Open timezone settings"
|
||||
data-testid="timezoneSettingsButton"
|
||||
>
|
||||
<Settings2 className="h-4 w-4 dark:text-foreground" />
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetWorkspaceMemberInvitesToManageDocument,
|
||||
useGetWorkspaceMemberInvitesToManageQuery,
|
||||
} from '@/generated/graphql';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { alpha } from '@mui/system';
|
||||
import { useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function InviteNotification() {
|
||||
const user = useUserData();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
const client = useApolloClient();
|
||||
const router = useRouter();
|
||||
const { submitState, setSubmitState } = useSubmitState();
|
||||
const { submitState: ignoreState, setSubmitState: setIgnoreState } =
|
||||
useSubmitState();
|
||||
|
||||
// @FIX: We probably don't want to poll every ten seconds for possible invites. (We can change later depending on how it works in production.) Maybe just on the workspace page?
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
refetch: refetchInvitations,
|
||||
startPolling,
|
||||
} = useGetWorkspaceMemberInvitesToManageQuery({
|
||||
variables: {
|
||||
userId: user?.id,
|
||||
},
|
||||
skip: !isPlatform || !user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(15000);
|
||||
}, [startPolling]);
|
||||
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
// TODO: Throw error instead and wrap this component in an ErrorBoundary
|
||||
// that would handle the error
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!data || data.workspaceMemberInvites.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleInviteAccept = async (
|
||||
_event: React.SyntheticEvent<HTMLButtonElement>,
|
||||
invite: (typeof data.workspaceMemberInvites)[number],
|
||||
) => {
|
||||
setSubmitState({
|
||||
error: null,
|
||||
loading: true,
|
||||
});
|
||||
const { res, error: acceptError } = await nhost.functions.call(
|
||||
'/accept-workspace-invite',
|
||||
{
|
||||
workspaceMemberInviteId: invite.id,
|
||||
isAccepted: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (res?.status !== 200) {
|
||||
triggerToast('An error occurred when trying to accept the invitation.');
|
||||
|
||||
return setSubmitState({
|
||||
error: new Error(acceptError.message),
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
|
||||
await client.refetchQueries({
|
||||
include: [
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetWorkspaceMemberInvitesToManageDocument,
|
||||
],
|
||||
});
|
||||
await router.push(`/${invite.workspace.slug}`);
|
||||
await refetchInvitations();
|
||||
triggerToast('Workspace invite accepted');
|
||||
return setSubmitState({
|
||||
error: null,
|
||||
loading: false,
|
||||
});
|
||||
};
|
||||
|
||||
async function handleIgnoreInvitation(
|
||||
inviteId: (typeof data.workspaceMemberInvites)[number]['id'],
|
||||
) {
|
||||
setIgnoreState({
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { error: ignoreError } = await nhost.functions.call(
|
||||
'/accept-workspace-invite',
|
||||
{
|
||||
workspaceMemberInviteId: inviteId,
|
||||
isAccepted: false,
|
||||
},
|
||||
);
|
||||
|
||||
if (ignoreError) {
|
||||
triggerToast('An error occurred when trying to ignore the invitation.');
|
||||
|
||||
setIgnoreState({
|
||||
loading: false,
|
||||
error: new Error(ignoreError.message),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// just refetch all data
|
||||
await client.refetchQueries({
|
||||
include: [
|
||||
GetAllWorkspacesAndProjectsDocument,
|
||||
GetWorkspaceMemberInvitesToManageDocument,
|
||||
],
|
||||
});
|
||||
|
||||
setIgnoreState({
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="absolute right-10 z-50 mt-14 w-workspaceSidebar rounded-lg px-6 py-6 text-left"
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? 'grey.200' : 'grey.700',
|
||||
borderWidth: (theme) => (theme.palette.mode === 'dark' ? 1 : 0),
|
||||
borderColor: (theme) =>
|
||||
theme.palette.mode === 'dark' ? theme.palette.grey[400] : 'none',
|
||||
}}
|
||||
>
|
||||
{data?.workspaceMemberInvites?.map(
|
||||
(invite: (typeof data.workspaceMemberInvites)[number]) => (
|
||||
<div key={invite.id} className="grid grid-flow-row gap-4 text-center">
|
||||
<div className="grid grid-flow-row gap-1">
|
||||
<Text variant="h3" component="h2" sx={{ color: 'common.white' }}>
|
||||
You have been invited to
|
||||
</Text>
|
||||
<Text variant="h3" component="p" sx={{ color: 'common.white' }}>
|
||||
{invite.workspace.name}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
onClick={(e: React.SyntheticEvent<HTMLButtonElement>) =>
|
||||
handleInviteAccept(e, invite)
|
||||
}
|
||||
loading={submitState.loading}
|
||||
>
|
||||
Accept Invite
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
sx={{
|
||||
color: 'common.white',
|
||||
'&:hover': {
|
||||
backgroundColor: (theme) =>
|
||||
alpha(theme.palette.common.white, 0.05),
|
||||
},
|
||||
'&:focus': {
|
||||
backgroundColor: (theme) =>
|
||||
alpha(theme.palette.common.white, 0.1),
|
||||
},
|
||||
}}
|
||||
onClick={() => handleIgnoreInvitation(invite.id)}
|
||||
loading={ignoreState.loading}
|
||||
>
|
||||
Ignore Invite
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as InviteNotification } from './InviteNotification';
|
||||
@@ -1,7 +1,6 @@
|
||||
import { render, screen } from '@/tests/orgs/testUtils';
|
||||
import { render, screen, TestUserEvent } from '@/tests/testUtils';
|
||||
import { guessTimezone } from '@/utils/timezoneUtils';
|
||||
import { TZDate } from '@date-fns/tz';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { format } from 'date-fns-v4';
|
||||
import { useState } from 'react';
|
||||
@@ -36,7 +35,7 @@ describe('TimePicker', () => {
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
'Time: 03:00:05',
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const user = new TestUserEvent();
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '18');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
@@ -46,7 +45,7 @@ describe('TimePicker', () => {
|
||||
|
||||
test('only valid hours(0-23), minutes(0-59) and seconds(0-59) are allowed', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
const user = userEvent.setup();
|
||||
const user = new TestUserEvent();
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '30');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
@@ -61,7 +60,7 @@ describe('TimePicker', () => {
|
||||
|
||||
test('Updates only the minutes of the date object', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
const user = userEvent.setup();
|
||||
const user = new TestUserEvent();
|
||||
const minutesInput = await screen.getByLabelText('Minutes');
|
||||
await user.type(minutesInput, '44');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
@@ -71,7 +70,7 @@ describe('TimePicker', () => {
|
||||
|
||||
test('Updates only the seconds of the date object', async () => {
|
||||
render(<TestComponent dateTime="2025-03-10T03:00:05" />);
|
||||
const user = userEvent.setup();
|
||||
const user = new TestUserEvent();
|
||||
const secondsInput = await screen.getByLabelText('Seconds');
|
||||
await user.type(secondsInput, '11');
|
||||
expect(await screen.getByText(/Time:/i)).toHaveTextContent(
|
||||
@@ -84,7 +83,7 @@ describe('TimePicker', () => {
|
||||
expect(await screen.getByText(/Date class:/i)).toHaveTextContent(
|
||||
'Date class: TZDate',
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const user = new TestUserEvent();
|
||||
|
||||
const hoursInput = await screen.getByLabelText('Hours');
|
||||
await user.type(hoursInput, '18');
|
||||
|
||||
@@ -3,12 +3,12 @@ import { Input } from '@/components/ui/v3/input';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React from 'react';
|
||||
import {
|
||||
type Period,
|
||||
type TimePickerType,
|
||||
copyDate,
|
||||
getArrowByType,
|
||||
getDateByType,
|
||||
setDateByType,
|
||||
type Period,
|
||||
type TimePickerType,
|
||||
} from './time-picker-utils';
|
||||
|
||||
export interface TimePickerInputProps
|
||||
|
||||
@@ -229,7 +229,7 @@ export function getArrowByType(
|
||||
}
|
||||
}
|
||||
|
||||
function isTZDate(date: Date | TZDate): date is TZDate {
|
||||
export function isTZDate(date: Date | TZDate): date is TZDate {
|
||||
return date instanceof TZDate;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,26 @@ interface Props {
|
||||
dateTime: string;
|
||||
}
|
||||
|
||||
function getOrderedTimezones(dateTime: string, selectedTimezone: string) {
|
||||
const [utcTimezone, browserTimezone, ...timezones] =
|
||||
createTimezoneOptions(dateTime);
|
||||
let orderedTimezones = [...timezones];
|
||||
if (
|
||||
selectedTimezone !== browserTimezone.value &&
|
||||
selectedTimezone !== 'UTC'
|
||||
) {
|
||||
const selectedTimezoneOption = timezones.find(
|
||||
(tz) => tz.value === selectedTimezone,
|
||||
);
|
||||
orderedTimezones = [
|
||||
selectedTimezoneOption,
|
||||
...timezones.filter((tz) => tz.value !== selectedTimezone),
|
||||
];
|
||||
}
|
||||
|
||||
return [utcTimezone, browserTimezone, ...orderedTimezones];
|
||||
}
|
||||
|
||||
function TimezonePicker({
|
||||
selectedTimezone,
|
||||
onTimezoneSelect,
|
||||
@@ -16,9 +36,10 @@ function TimezonePicker({
|
||||
dateTime,
|
||||
}: Props) {
|
||||
const timezoneOptions = useMemo(
|
||||
() => createTimezoneOptions(dateTime),
|
||||
[dateTime],
|
||||
() => getOrderedTimezones(dateTime, selectedTimezone),
|
||||
[dateTime, selectedTimezone],
|
||||
);
|
||||
|
||||
return (
|
||||
<VirtualizedCombobox
|
||||
options={timezoneOptions}
|
||||
@@ -27,6 +48,7 @@ function TimezonePicker({
|
||||
searchPlaceholder="Search timezones..."
|
||||
button={button}
|
||||
side="right"
|
||||
width="370px"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,20 +105,12 @@ function VirtualizedCommand<O extends Option>({
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedOption) {
|
||||
const option = filteredOptions.find(
|
||||
(opt) => opt.value === selectedOption,
|
||||
);
|
||||
if (option) {
|
||||
const index = filteredOptions.indexOf(option);
|
||||
setFocusedIndex(index);
|
||||
}
|
||||
}
|
||||
}, [selectedOption, filteredOptions, virtualizer]);
|
||||
|
||||
return (
|
||||
<Command shouldFilter={false} onKeyDown={handleKeyDown}>
|
||||
<Command
|
||||
shouldFilter={false}
|
||||
onKeyDown={handleKeyDown}
|
||||
value={selectedOption}
|
||||
>
|
||||
<CommandInput onValueChange={handleSearch} placeholder={placeholder} />
|
||||
<CommandList
|
||||
ref={parentRef}
|
||||
@@ -145,7 +137,6 @@ function VirtualizedCommand<O extends Option>({
|
||||
filteredOptions[virtualOption.index].key ??
|
||||
filteredOptions[virtualOption.index].value
|
||||
}
|
||||
disabled={isKeyboardNavActive}
|
||||
className={cn(
|
||||
'absolute left-0 top-0 w-full bg-transparent',
|
||||
focusedIndex === virtualOption.index &&
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { render, screen } from '@/tests/testUtils';
|
||||
import type { Column } from 'react-table';
|
||||
import { expect, test } from 'vitest';
|
||||
import DataGrid from './DataGrid';
|
||||
|
||||
interface MockDataDetails {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const mockColumns: Column<MockDataDetails>[] = [
|
||||
{ id: 'id', Header: 'ID', accessor: 'id' },
|
||||
{ id: 'name', Header: 'Name', accessor: 'name' },
|
||||
];
|
||||
|
||||
const mockData: MockDataDetails[] = [
|
||||
{ id: 1, name: 'foo' },
|
||||
{ id: 2, name: 'bar' },
|
||||
];
|
||||
|
||||
test('should render an empty state if columns are not available', () => {
|
||||
render(<DataGrid columns={[]} data={[]} />);
|
||||
|
||||
expect(screen.getByText(/columns not found/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render columns and empty state message if data is unavailable', () => {
|
||||
render(<DataGrid columns={mockColumns} data={[]} />);
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('columnheader', { name: /id/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/no data is available/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render custom empty state message if data is unavailable', () => {
|
||||
const customEmptyStateMessage = 'custom empty state message';
|
||||
|
||||
render(
|
||||
<DataGrid
|
||||
columns={mockColumns}
|
||||
data={[]}
|
||||
emptyStateMessage={customEmptyStateMessage}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(customEmptyStateMessage)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display a loading indicator', async () => {
|
||||
render(<DataGrid columns={mockColumns} data={[]} loading />);
|
||||
|
||||
// Activity indicator is not immediately displayed, so we need to wait
|
||||
expect(await screen.findByRole('progressbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render data if provided', () => {
|
||||
render(<DataGrid columns={mockColumns} data={mockData} />);
|
||||
|
||||
expect(screen.getAllByRole('row')).toHaveLength(2);
|
||||
expect(screen.getByRole('cell', { name: /1/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('cell', { name: /foo/i })).toBeInTheDocument();
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import type { UseDataGridOptions } from '@/components/dataGrid/DataGrid/useDataGrid';
|
||||
import { DataGridBody } from '@/components/dataGrid/DataGridBody';
|
||||
import { DataGridConfigProvider } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import { DataGridFrame } from '@/components/dataGrid/DataGridFrame';
|
||||
import type { DataGridHeaderProps } from '@/components/dataGrid/DataGridHeader';
|
||||
import { DataGridHeader } from '@/components/dataGrid/DataGridHeader';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { DataBrowserEmptyState } from '@/features/database/dataGrid/components/DataBrowserEmptyState';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { ForwardedRef } from 'react';
|
||||
import { forwardRef, useEffect, useRef } from 'react';
|
||||
import mergeRefs from 'react-merge-refs';
|
||||
import type { Column, Row, SortingRule, TableOptions } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import useDataGrid from './useDataGrid';
|
||||
|
||||
export interface DataGridProps<TColumnData extends object>
|
||||
extends Omit<UseDataGridOptions<TColumnData>, 'tableRef'> {
|
||||
/**
|
||||
* Available columns.
|
||||
*/
|
||||
columns: Column<TColumnData>[];
|
||||
/**
|
||||
* Data to be displayed in the table.
|
||||
*/
|
||||
data: any[];
|
||||
/**
|
||||
* Text to be displayed when no data is available in the data grid.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
emptyStateMessage?: string;
|
||||
/**
|
||||
* Additional configuration options for the `react-table` hook.
|
||||
*/
|
||||
options?: Omit<TableOptions<TColumnData>, 'columns' | 'data'>;
|
||||
/**
|
||||
* Additional data grid controls. This component will be part of the Data Grid
|
||||
* context, so it can use Data Grid configuration.
|
||||
*/
|
||||
controls?:
|
||||
| React.ReactNode
|
||||
| ((selectedFlatRows: Row<TColumnData>[]) => React.ReactNode);
|
||||
/**
|
||||
* Function to be called when columns are sorted in the table.
|
||||
*/
|
||||
onSort?: (args: SortingRule<TColumnData>[]) => void;
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new row.
|
||||
*/
|
||||
onInsertRow?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new column.
|
||||
*/
|
||||
onInsertColumn?: VoidFunction;
|
||||
/**
|
||||
* Function to be called when the user wants to remove a column.
|
||||
*/
|
||||
onRemoveColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
|
||||
/**
|
||||
* Function to be called when the user wants to edit a column.
|
||||
*/
|
||||
onEditColumn?: (column: DataBrowserGridColumn<TColumnData>) => void;
|
||||
/**
|
||||
* Determines whether or not data is loading.
|
||||
*/
|
||||
loading?: boolean;
|
||||
/**
|
||||
* Class name to be applied to the data grid.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Sort configuration.
|
||||
*/
|
||||
sortBy?: SortingRule<TColumnData>[];
|
||||
/**
|
||||
* Props to be passed to the `DataGridHeader` component.
|
||||
*/
|
||||
headerProps?: DataGridHeaderProps<TColumnData>;
|
||||
}
|
||||
|
||||
function DataGrid<TColumnData extends object>(
|
||||
{
|
||||
columns,
|
||||
data,
|
||||
allowSelection,
|
||||
allowSort,
|
||||
allowResize,
|
||||
emptyStateMessage,
|
||||
options = {},
|
||||
headerProps,
|
||||
controls,
|
||||
sortBy,
|
||||
onSort,
|
||||
onInsertRow,
|
||||
onInsertColumn,
|
||||
onEditColumn,
|
||||
onRemoveColumn,
|
||||
loading,
|
||||
className,
|
||||
}: DataGridProps<TColumnData>,
|
||||
ref: ForwardedRef<HTMLDivElement>,
|
||||
) {
|
||||
const tableRef = useRef<HTMLDivElement>();
|
||||
const { toggleAllRowsSelected, setSortBy, ...dataGridProps } =
|
||||
useDataGrid<TColumnData>({
|
||||
columns: columns || [],
|
||||
data: data || [],
|
||||
allowSelection,
|
||||
allowSort,
|
||||
allowResize,
|
||||
...options,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!sortBy && setSortBy) {
|
||||
setSortBy([]);
|
||||
}
|
||||
}, [setSortBy, sortBy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSort && allowSort) {
|
||||
onSort(dataGridProps.state.sortBy);
|
||||
|
||||
if (toggleAllRowsSelected) {
|
||||
toggleAllRowsSelected(false);
|
||||
}
|
||||
}
|
||||
}, [allowSort, dataGridProps.state.sortBy, onSort, toggleAllRowsSelected]);
|
||||
|
||||
return (
|
||||
<DataGridConfigProvider
|
||||
toggleAllRowsSelected={toggleAllRowsSelected}
|
||||
setSortBy={setSortBy}
|
||||
tableRef={tableRef}
|
||||
{...dataGridProps}
|
||||
>
|
||||
<>
|
||||
{controls}
|
||||
|
||||
{columns.length === 0 && !loading && (
|
||||
<DataBrowserEmptyState
|
||||
title="Columns not found"
|
||||
description="Please create a column before adding data to the table."
|
||||
/>
|
||||
)}
|
||||
|
||||
{columns.length > 0 && (
|
||||
<Box
|
||||
ref={mergeRefs([ref, tableRef])}
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className={twMerge(
|
||||
'overflow-x-auto',
|
||||
!loading && 'h-full',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<DataGridFrame>
|
||||
<DataGridHeader
|
||||
onInsertColumn={onInsertColumn}
|
||||
onEditColumn={onEditColumn}
|
||||
onRemoveColumn={onRemoveColumn}
|
||||
{...headerProps}
|
||||
/>
|
||||
|
||||
<DataGridBody
|
||||
emptyStateMessage={emptyStateMessage}
|
||||
loading={loading}
|
||||
onInsertRow={onInsertRow}
|
||||
allowInsertColumn={Boolean(onRemoveColumn)}
|
||||
/>
|
||||
</DataGridFrame>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{loading && <ActivityIndicator delay={1000} className="my-4" />}
|
||||
</>
|
||||
</DataGridConfigProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(DataGrid) as <TColumnData extends object>(
|
||||
props: DataGridProps<TColumnData> & { ref?: ForwardedRef<HTMLDivElement> },
|
||||
) => ReturnType<typeof DataGrid>;
|
||||
@@ -1,4 +0,0 @@
|
||||
export * from './DataGrid';
|
||||
export { default as DataGrid } from './DataGrid';
|
||||
export * from './useDataGrid';
|
||||
export { default as useDataGrid } from './useDataGrid';
|
||||
@@ -1,110 +0,0 @@
|
||||
import { Checkbox } from '@/components/ui/v2/Checkbox';
|
||||
import type { MutableRefObject } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import type { PluginHook, TableInstance, TableOptions } from 'react-table';
|
||||
import {
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useRowSelect,
|
||||
useSortBy,
|
||||
useTable,
|
||||
} from 'react-table';
|
||||
|
||||
export interface UseDataGridBaseOptions {
|
||||
/**
|
||||
* Determines whether data grid columns are selectable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowSelection?: boolean;
|
||||
/**
|
||||
* Determines whether data grid columns are sortable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowSort?: boolean;
|
||||
/**
|
||||
* Determine whether data grid columns are resizable.
|
||||
*
|
||||
* @default false
|
||||
*/
|
||||
allowResize?: boolean;
|
||||
/**
|
||||
* Reference to the data grid root element.
|
||||
*/
|
||||
tableRef?: MutableRefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export type UseDataGridOptions<T extends object = {}> = TableOptions<T> &
|
||||
UseDataGridBaseOptions;
|
||||
export type UseDataGridReturn<T extends object = {}> = TableInstance<T> &
|
||||
UseDataGridBaseOptions;
|
||||
|
||||
export default function useDataGrid<T extends object>(
|
||||
{ allowSelection, allowSort, allowResize, ...options }: UseDataGridOptions<T>,
|
||||
...plugins: PluginHook<T>[]
|
||||
): UseDataGridReturn<T> {
|
||||
const defaultColumn = useMemo(
|
||||
() => ({
|
||||
width: 32,
|
||||
minWidth: 32,
|
||||
Cell: ({ value }: { value: any }) => (
|
||||
<span className="truncate">
|
||||
{typeof value === 'object' ? JSON.stringify(value) : value}
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const pluginHooks = [
|
||||
useBlockLayout,
|
||||
useResizeColumns,
|
||||
useSortBy,
|
||||
useRowSelect,
|
||||
];
|
||||
|
||||
const tableData = useTable<T>(
|
||||
{
|
||||
defaultColumn,
|
||||
...options,
|
||||
},
|
||||
...pluginHooks,
|
||||
...plugins,
|
||||
(hooks) =>
|
||||
allowSelection
|
||||
? hooks.visibleColumns.push((columns) => [
|
||||
{
|
||||
id: 'selection',
|
||||
Header: ({ rows, getToggleAllRowsSelectedProps }: any) => (
|
||||
<Checkbox
|
||||
disabled={rows.length === 0}
|
||||
{...getToggleAllRowsSelectedProps({ style: null })}
|
||||
style={{
|
||||
...getToggleAllRowsSelectedProps().style,
|
||||
cursor: rows.length === 0 ? 'default' : 'pointer',
|
||||
}}
|
||||
/>
|
||||
),
|
||||
Cell: ({ row }: any) => {
|
||||
const originalValue = row.original as any;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
{...row.getToggleRowSelectedProps()}
|
||||
// disable selection if row is just a upload preview
|
||||
checked={originalValue.uploading ? false : row.isSelected}
|
||||
disabled={originalValue.uploading}
|
||||
/>
|
||||
);
|
||||
},
|
||||
disableSortBy: true,
|
||||
disableResizing: true,
|
||||
},
|
||||
...columns,
|
||||
])
|
||||
: hooks.visibleColumns,
|
||||
);
|
||||
|
||||
return { ...tableData, allowSort, allowResize, allowSelection };
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { DataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps, KeyboardEvent } from 'react';
|
||||
import { Fragment, useMemo, useRef } from 'react';
|
||||
import type { Row } from 'react-table';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DataGridBodyProps<T extends object>
|
||||
extends Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>,
|
||||
Pick<DataGridProps<T>, 'onInsertRow' | 'emptyStateMessage' | 'loading'> {
|
||||
/**
|
||||
* Determines whether column insertion is allowed.
|
||||
*/
|
||||
allowInsertColumn?: boolean;
|
||||
}
|
||||
|
||||
interface InsertPlaceholderTableRowProps extends BoxProps {
|
||||
/**
|
||||
* Function to be called when the user wants to insert a new row.
|
||||
*/
|
||||
onInsertRow: VoidFunction;
|
||||
}
|
||||
|
||||
function InsertPlaceholderTableRow({
|
||||
onInsertRow,
|
||||
...props
|
||||
}: InsertPlaceholderTableRowProps) {
|
||||
return (
|
||||
<Box className="h-12 border-b-1 border-r-1" {...props}>
|
||||
<Button
|
||||
onClick={onInsertRow}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full justify-start rounded-none px-2 py-3 text-xs font-normal hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
startIcon={
|
||||
<PlusIcon className="h-4 w-4" sx={{ color: 'text.secondary' }} />
|
||||
}
|
||||
>
|
||||
Insert New Row
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Get rid of Data Browser related code from here. This component should
|
||||
// be generic and not depend on Data Browser related data types and logic.
|
||||
export default function DataGridBody<T extends object>({
|
||||
emptyStateMessage = 'No data is available',
|
||||
loading,
|
||||
onInsertRow,
|
||||
allowInsertColumn,
|
||||
...props
|
||||
}: DataGridBodyProps<T>) {
|
||||
const { getTableBodyProps, totalColumnsWidth, rows, prepareRow, columns } =
|
||||
useDataGridConfig<T>();
|
||||
|
||||
const SELECTION_CELL_WIDTH = 32;
|
||||
const ADD_COLUMN_CELL_WIDTH = 100;
|
||||
const bodyRef = useRef<HTMLDivElement>();
|
||||
|
||||
const primaryAndUniqueKeys = useMemo(
|
||||
() =>
|
||||
columns
|
||||
.filter(
|
||||
(column: DataBrowserGridColumn<T>) =>
|
||||
column.isPrimary || column.isUnique,
|
||||
)
|
||||
.map((column) => column.id),
|
||||
[columns],
|
||||
);
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>, row: Row<T>) {
|
||||
const { id: rowId } = row;
|
||||
const cellId = document.activeElement.id;
|
||||
|
||||
const currentRow = bodyRef.current.children.namedItem(rowId);
|
||||
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentRow.previousElementSibling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellInPreviousRow =
|
||||
currentRow.previousElementSibling.children.namedItem(cellId);
|
||||
|
||||
if (cellInPreviousRow instanceof HTMLElement) {
|
||||
cellInPreviousRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
});
|
||||
cellInPreviousRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
|
||||
if (!currentRow.nextElementSibling) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cellInNextRow =
|
||||
currentRow.nextElementSibling.children.namedItem(cellId);
|
||||
|
||||
if (cellInNextRow instanceof HTMLElement) {
|
||||
cellInNextRow.scrollIntoView({ block: 'nearest' });
|
||||
cellInNextRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'ArrowLeft' || (event.shiftKey && event.key === 'Tab')) {
|
||||
let previousFocusableCellInRow: HTMLElement;
|
||||
let previousFocusableCellInRowFound = false;
|
||||
|
||||
currentRow.childNodes.forEach((node) => {
|
||||
if (node === currentRow.children.namedItem(cellId)) {
|
||||
previousFocusableCellInRowFound = true;
|
||||
}
|
||||
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.tabIndex > -1 &&
|
||||
!previousFocusableCellInRowFound
|
||||
) {
|
||||
previousFocusableCellInRow = node;
|
||||
}
|
||||
});
|
||||
|
||||
if (previousFocusableCellInRow) {
|
||||
event.preventDefault();
|
||||
|
||||
previousFocusableCellInRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
previousFocusableCellInRow.focus();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
event.key === 'ArrowRight' ||
|
||||
(!event.shiftKey && event.key === 'Tab')
|
||||
) {
|
||||
let nextFocusableCellInRow: HTMLElement;
|
||||
let nextFocusableCellInRowFound = false;
|
||||
|
||||
currentRow.childNodes.forEach((node) => {
|
||||
if (
|
||||
node instanceof HTMLElement &&
|
||||
node.tabIndex > -1 &&
|
||||
parseInt(node.id, 10) > parseInt(cellId, 10) &&
|
||||
!nextFocusableCellInRowFound
|
||||
) {
|
||||
nextFocusableCellInRowFound = true;
|
||||
nextFocusableCellInRow = node;
|
||||
}
|
||||
});
|
||||
|
||||
if (nextFocusableCellInRow) {
|
||||
event.preventDefault();
|
||||
|
||||
nextFocusableCellInRow.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'center',
|
||||
});
|
||||
nextFocusableCellInRow.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getBackgroundCellColor = (
|
||||
row: Row<T>,
|
||||
column: DataBrowserGridColumn<T>,
|
||||
) => {
|
||||
// Grey out files not uploaded
|
||||
if (!row.values.isUploaded) {
|
||||
return 'grey.200';
|
||||
}
|
||||
|
||||
if (column.isDisabled) {
|
||||
return 'grey.100';
|
||||
}
|
||||
|
||||
return 'background.paper';
|
||||
};
|
||||
|
||||
return (
|
||||
<div {...getTableBodyProps()} ref={bodyRef} {...props}>
|
||||
{rows.length === 0 && !loading && (
|
||||
<div className="flex flex-nowrap pr-5">
|
||||
{onInsertRow ? (
|
||||
<InsertPlaceholderTableRow
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth - SELECTION_CELL_WIDTH,
|
||||
}}
|
||||
onInsertRow={onInsertRow}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
className="inline-flex h-12 items-center border-b-1 border-r-1 px-2 py-1.5 text-xs"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
style={{
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth,
|
||||
}}
|
||||
>
|
||||
{emptyStateMessage}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rows.map((row, index) => {
|
||||
let rowKey = index.toString();
|
||||
|
||||
if (primaryAndUniqueKeys && primaryAndUniqueKeys.length > 0) {
|
||||
rowKey = primaryAndUniqueKeys
|
||||
.map((key) => row.values[key])
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
} else {
|
||||
rowKey = `${index}-${Object.keys(row.values)
|
||||
.map((key) => String(row.values[key]))
|
||||
.join('-')}`;
|
||||
}
|
||||
|
||||
prepareRow(row);
|
||||
|
||||
const rowProps = row.getRowProps({
|
||||
style: {
|
||||
width: allowInsertColumn
|
||||
? totalColumnsWidth + ADD_COLUMN_CELL_WIDTH
|
||||
: totalColumnsWidth,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment key={rowKey.toString()}>
|
||||
<div
|
||||
{...rowProps}
|
||||
id={row.id}
|
||||
className="flex scroll-mt-10"
|
||||
role="row"
|
||||
onKeyDown={(event) => handleKeyDown(event, row)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{row.cells.map((cell, cellIndex) => {
|
||||
const column = cell.column as DataBrowserGridColumn<T>;
|
||||
const isCellDisabled =
|
||||
cell.value !== 0 &&
|
||||
!cell.value &&
|
||||
column.type !== 'boolean' &&
|
||||
column.id !== 'selection' &&
|
||||
column.isDisabled;
|
||||
|
||||
return (
|
||||
<DataGridCell
|
||||
{...cell.getCellProps({
|
||||
style: {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})}
|
||||
cell={cell}
|
||||
sx={{
|
||||
backgroundColor: getBackgroundCellColor(row, column),
|
||||
color: isCellDisabled ? 'text.secondary' : 'text.primary',
|
||||
}}
|
||||
className={twMerge(
|
||||
'h-12 font-display text-xs motion-safe:transition-colors',
|
||||
'border-b-1 border-r-1',
|
||||
'scroll-ml-8 scroll-mt-[57px]',
|
||||
column.id === 'selection' &&
|
||||
'sticky left-0 z-20 justify-center px-0',
|
||||
)}
|
||||
isEditable={!column.isDisabled && column.isEditable}
|
||||
id={cellIndex.toString()}
|
||||
key={column.id}
|
||||
>
|
||||
{cell.render('Cell')}
|
||||
</DataGridCell>
|
||||
);
|
||||
})}
|
||||
|
||||
{allowInsertColumn && (
|
||||
<Box className="h-12 w-25 border-b-1 border-r-1" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onInsertRow && index === rows.length - 1 && (
|
||||
<InsertPlaceholderTableRow
|
||||
{...rowProps}
|
||||
key=""
|
||||
onInsertRow={onInsertRow}
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridBody';
|
||||
export { default as DataGridBody } from './DataGridBody';
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { ReadOnlyToggle } from '@/components/presentational/ReadOnlyToggle';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import type { MouseEvent, KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export type DataGridBooleanCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, boolean | null>;
|
||||
|
||||
export default function DataGridBooleanCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { isNullable },
|
||||
},
|
||||
}: DataGridBooleanCellProps<TData>) {
|
||||
const {
|
||||
inputRef,
|
||||
isEditing,
|
||||
focusCell,
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
isSelected,
|
||||
} = useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleMenuClick(
|
||||
event: MouseEvent<HTMLLIElement> | ReactKeyboardEvent<HTMLLIElement>,
|
||||
value: boolean | null,
|
||||
) {
|
||||
event.stopPropagation();
|
||||
await onSave(value);
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
async function handleMenuKeyDown(event: ReactKeyboardEvent<HTMLDivElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// We need to restore the temporary value, because editing was cancelled
|
||||
if (event.key === 'Escape' && onTemporaryValueChange) {
|
||||
event.stopPropagation();
|
||||
|
||||
onTemporaryValueChange(optimisticValue);
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab' && onSave) {
|
||||
await onSave(temporaryValue);
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleTemporaryValueChange(value: boolean | null) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
return isSelected ? (
|
||||
<Dropdown.Root id="boolean-data-editor" className="h-full w-full">
|
||||
<Dropdown.Trigger
|
||||
id="boolean-trigger"
|
||||
className={twMerge(
|
||||
'h-full w-full border-none p-0 outline-none',
|
||||
isEditing && 'p-1.5',
|
||||
)}
|
||||
ref={inputRef}
|
||||
onClick={editCell}
|
||||
autoFocus={false}
|
||||
sx={{ '&:hover': { backgroundColor: 'transparent !important' } }}
|
||||
>
|
||||
<ReadOnlyToggle checked={optimisticValue} />
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
disablePortal
|
||||
onKeyDown={handleMenuKeyDown}
|
||||
PaperProps={{ className: 'w-[200px]' }}
|
||||
TransitionProps={{ onExited: focusCell }}
|
||||
>
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === true}
|
||||
onKeyUp={() => handleTemporaryValueChange(true)}
|
||||
onClick={(event) => handleMenuClick(event, true)}
|
||||
>
|
||||
<ReadOnlyToggle checked />
|
||||
</Dropdown.Item>
|
||||
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === false}
|
||||
onKeyUp={() => handleTemporaryValueChange(false)}
|
||||
onClick={(event) => handleMenuClick(event, false)}
|
||||
>
|
||||
<ReadOnlyToggle checked={false} />
|
||||
</Dropdown.Item>
|
||||
|
||||
{isNullable && (
|
||||
<Dropdown.Item
|
||||
selected={optimisticValue === null}
|
||||
onKeyUp={() => handleTemporaryValueChange(null)}
|
||||
onClick={(event) => handleMenuClick(event, null)}
|
||||
>
|
||||
<ReadOnlyToggle checked={null} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
) : (
|
||||
<ReadOnlyToggle checked={optimisticValue} />
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridBooleanCell';
|
||||
export { default as DataGridBooleanCell } from './DataGridBooleanCell';
|
||||
@@ -1,381 +0,0 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Tooltip, useTooltip } from '@/components/ui/v2/Tooltip';
|
||||
import type {
|
||||
ColumnType,
|
||||
DataBrowserGridCell,
|
||||
DataBrowserGridCellProps,
|
||||
} from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import type {
|
||||
FocusEvent,
|
||||
JSXElementConstructor,
|
||||
KeyboardEvent,
|
||||
MouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
ReactPortal,
|
||||
} from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import DataGridCellProvider from './DataGridCellProvider';
|
||||
import useDataGridCell from './useDataGridCell';
|
||||
|
||||
export interface CommonDataGridCellProps<TData extends object, TValue = any>
|
||||
extends DataBrowserGridCellProps<TData, TValue> {
|
||||
/**
|
||||
* Function that is called when the cell is saved.
|
||||
*/
|
||||
onSave?: (value: TValue, options?: { reset: boolean }) => Promise<void>;
|
||||
/**
|
||||
* Optimistic value for the cell.
|
||||
*/
|
||||
optimisticValue?: TValue;
|
||||
/**
|
||||
* Function to be called when the optimistic value should be changed.
|
||||
*/
|
||||
onOptimisticValueChange?: (value: TValue) => void;
|
||||
/**
|
||||
* Temporary value for the cell. This is used for storing the current input
|
||||
* value, that should be later saved as an optimistic value before saving the
|
||||
* data.
|
||||
*/
|
||||
temporaryValue?: TValue;
|
||||
/**
|
||||
* Function to be called when the temporary value should be changed.
|
||||
*/
|
||||
onTemporaryValueChange?: (value: TValue) => void;
|
||||
}
|
||||
|
||||
export interface DataGridCellProps<TData extends object, TValue = unknown>
|
||||
extends BoxProps {
|
||||
/**
|
||||
* Current cell's props.
|
||||
*/
|
||||
cell: DataBrowserGridCell<TData, TValue>;
|
||||
/**
|
||||
* Determines whether the cell is editable.
|
||||
*/
|
||||
isEditable?: boolean;
|
||||
/**
|
||||
* Determines the column's type.
|
||||
*/
|
||||
columnType?: ColumnType;
|
||||
}
|
||||
|
||||
function DataGridCellContent<TData extends object = {}, TValue = unknown>({
|
||||
isEditable,
|
||||
children,
|
||||
className,
|
||||
cell: {
|
||||
value: originalValue,
|
||||
column: { onCellEdit, id, isNullable, isPrimary, type },
|
||||
row,
|
||||
},
|
||||
...props
|
||||
}: DataGridCellProps<TData, TValue>) {
|
||||
const { openAlertDialog } = useDialog();
|
||||
|
||||
const {
|
||||
title: tooltipTitle,
|
||||
open: tooltipOpen,
|
||||
openTooltip,
|
||||
closeTooltip,
|
||||
resetTooltipTitle,
|
||||
} = useTooltip();
|
||||
|
||||
const [optimisticValue, setOptimisticValue] = useState<TValue>(originalValue);
|
||||
const [temporaryValue, setTemporaryValue] = useState<TValue>(originalValue);
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticValue(originalValue);
|
||||
setTemporaryValue(originalValue);
|
||||
}, [originalValue]);
|
||||
|
||||
const {
|
||||
cellRef,
|
||||
inputRef,
|
||||
focusCell,
|
||||
focusInput,
|
||||
blurInput,
|
||||
clickInput,
|
||||
isEditing,
|
||||
isSelected,
|
||||
selectCell,
|
||||
deselectCell,
|
||||
cancelEditCell,
|
||||
editCell,
|
||||
focusPrevCell,
|
||||
focusNextCell,
|
||||
} = useDataGridCell();
|
||||
|
||||
function activateInput() {
|
||||
if (isPrimary) {
|
||||
openTooltip("Primary keys can't be edited.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
editCell();
|
||||
|
||||
if (type === 'boolean') {
|
||||
clickInput();
|
||||
} else {
|
||||
focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick(event: MouseEvent<HTMLDivElement>) {
|
||||
if (!isEditable || isEditing || isPrimary) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail === 2 && type !== 'boolean') {
|
||||
editCell();
|
||||
await focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectCell();
|
||||
}
|
||||
|
||||
async function handleSave(
|
||||
value: TValue,
|
||||
options: { reset: boolean } = { reset: false },
|
||||
) {
|
||||
if (!onCellEdit) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedValue =
|
||||
value !== null && typeof value === 'object'
|
||||
? JSON.stringify(value)
|
||||
: String(value);
|
||||
|
||||
const normalizedOptimisticValue =
|
||||
optimisticValue !== null && typeof optimisticValue === 'object'
|
||||
? JSON.stringify(optimisticValue)
|
||||
: String(optimisticValue);
|
||||
|
||||
// We are making sure that optimistic value is not equal to the current
|
||||
// value. If it is, we are not going to save the value.
|
||||
if (
|
||||
normalizedValue.replace(/\n/gi, '\\n') ===
|
||||
normalizedOptimisticValue.replace(/\n/gi, '\\n') &&
|
||||
!options.reset
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In case of an error, we need to reset optimistic value
|
||||
const latestOptimisticValue = optimisticValue;
|
||||
|
||||
setOptimisticValue(value);
|
||||
|
||||
try {
|
||||
const data = await onCellEdit({
|
||||
row,
|
||||
columnsToUpdate: {
|
||||
[id]: {
|
||||
value: !options.reset ? value : undefined,
|
||||
reset: options.reset,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Syncing optimistic value with server-side value
|
||||
setTemporaryValue(data.original[id.toString()]);
|
||||
setOptimisticValue(data.original[id.toString()]);
|
||||
} catch (error) {
|
||||
triggerToast(`Error: ${error.message || 'Unknown error occurred.'}`);
|
||||
|
||||
// Resetting values
|
||||
setTemporaryValue(latestOptimisticValue);
|
||||
setOptimisticValue(latestOptimisticValue);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBlur(event: FocusEvent<HTMLDivElement>) {
|
||||
// We are deselecting cell only if focus target is not a descendant of it.
|
||||
if (!isEditable || event.currentTarget.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleSave(temporaryValue);
|
||||
closeTooltip();
|
||||
deselectCell();
|
||||
}
|
||||
|
||||
function resetCell() {
|
||||
if (isPrimary) {
|
||||
openTooltip('Primary keys are non-nullable.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isNullable) {
|
||||
openTooltip(
|
||||
<span>
|
||||
<strong>{id}</strong>
|
||||
is non-nullable.
|
||||
</span>,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
openAlertDialog({
|
||||
title: 'Set value to null',
|
||||
payload: (
|
||||
<p>
|
||||
Are you sure you want to set this cell to <strong>null</strong>?
|
||||
</p>
|
||||
),
|
||||
props: {
|
||||
primaryButtonText: 'Set to null',
|
||||
primaryButtonColor: 'error',
|
||||
onPrimaryAction: async () => {
|
||||
await handleSave(null, { reset: true });
|
||||
focusCell();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
closeTooltip();
|
||||
}
|
||||
|
||||
// Resetting temporary value and focusing cell on Escape when input field is
|
||||
// focused
|
||||
if (event.key === 'Escape' && event.target === inputRef.current) {
|
||||
setTemporaryValue(optimisticValue);
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
// Activating input field on Enter
|
||||
if (event.key === 'Enter' && event.target === cellRef.current) {
|
||||
activateInput();
|
||||
}
|
||||
|
||||
// Focusing next cell on Tab
|
||||
if (event.key === 'Tab' && !event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
const nextCellAvailable = focusNextCell();
|
||||
|
||||
if (!nextCellAvailable) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await blurInput();
|
||||
await focusCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Focusing previous cell on Shift-Tab
|
||||
if (event.key === 'Tab' && event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
const prevCellAvailable = focusPrevCell();
|
||||
|
||||
if (!prevCellAvailable) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
await blurInput();
|
||||
await focusCell();
|
||||
}
|
||||
}
|
||||
|
||||
// Initiating cell reset when cell is focused
|
||||
if (event.key === 'Backspace' && event.target === cellRef.current) {
|
||||
resetCell();
|
||||
}
|
||||
}
|
||||
|
||||
const content = (
|
||||
<Box
|
||||
ref={cellRef}
|
||||
className={twMerge(
|
||||
'relative grid h-full w-full cursor-default grid-flow-col items-center gap-1',
|
||||
isEditable &&
|
||||
'focus-within:outline-none focus-within:ring-0 focus:ring-0',
|
||||
isSelected && 'shadow-outline',
|
||||
isEditing ? 'p-0.5 shadow-outline-dark' : 'px-2 py-1.5',
|
||||
className,
|
||||
)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={isEditable ? 0 : undefined}
|
||||
onClick={handleClick}
|
||||
role="textbox"
|
||||
sx={{ backgroundColor: 'transparent' }}
|
||||
{...props}
|
||||
>
|
||||
{Children.map(
|
||||
children,
|
||||
(
|
||||
child:
|
||||
| ReactNode
|
||||
| ReactPortal
|
||||
| ReactElement<unknown, string | JSXElementConstructor<any>>,
|
||||
) => {
|
||||
if (!isValidElement(child)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cloneElement(child, {
|
||||
...child.props,
|
||||
onSave: handleSave,
|
||||
optimisticValue,
|
||||
onOptimisticValueChange: setOptimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange: setTemporaryValue,
|
||||
});
|
||||
},
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isEditable) {
|
||||
return (
|
||||
<Tooltip
|
||||
disableHoverListener
|
||||
disableFocusListener
|
||||
open={tooltipOpen}
|
||||
title={tooltipTitle || ''}
|
||||
TransitionProps={{ onExited: resetTooltipTitle }}
|
||||
>
|
||||
{content}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export default function DataGridCell<TData extends object, TValue = unknown>(
|
||||
props: DataGridCellProps<TData, TValue>,
|
||||
) {
|
||||
return (
|
||||
<DataGridCellProvider>
|
||||
<DataGridCellContent {...props} />
|
||||
</DataGridCellProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import type { MutableRefObject, PropsWithChildren } from 'react';
|
||||
import { createContext, useCallback, useMemo, useReducer, useRef } from 'react';
|
||||
|
||||
export interface DataGridCellContextProps<T extends HTMLElement> {
|
||||
/**
|
||||
* This `ref` should be attached to the cell element.
|
||||
*/
|
||||
cellRef: MutableRefObject<HTMLDivElement>;
|
||||
/**
|
||||
* This `ref` should be attached to the input element inside the data grid cell.
|
||||
*/
|
||||
inputRef: MutableRefObject<T>;
|
||||
/**
|
||||
* Determines whether or not the cell is currently being edited.
|
||||
*/
|
||||
isEditing: boolean;
|
||||
/**
|
||||
* Determines whether or not the cell is currently selected.
|
||||
*/
|
||||
isSelected: boolean;
|
||||
/**
|
||||
* Function to be called to start editing.
|
||||
*/
|
||||
editCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to cancel editing.
|
||||
*/
|
||||
cancelEditCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to select the cell, but not start editing.
|
||||
*/
|
||||
selectCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to deselect cell and cancel editing.
|
||||
*/
|
||||
deselectCell: VoidFunction;
|
||||
/**
|
||||
* Function to be called to focus cell.
|
||||
*/
|
||||
focusCell: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to blur cell.
|
||||
*/
|
||||
blurCell: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programatically focus the input in the cell.
|
||||
*/
|
||||
focusInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programatically blur the input in the cell.
|
||||
*/
|
||||
blurInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to programmatically click the input in the cell.
|
||||
*/
|
||||
clickInput: () => Promise<void>;
|
||||
/**
|
||||
* Function to be called to navigate to next cell if available.
|
||||
*
|
||||
* @returns `true` if there is a next cell to focus, `false` otherwise.
|
||||
*/
|
||||
focusNextCell: () => boolean;
|
||||
/**
|
||||
* Function to be called to navigate to previous cell if available.
|
||||
*
|
||||
* @returns `true` if there is a previous cell to focus, `false` otherwise.
|
||||
*/
|
||||
focusPrevCell: () => boolean;
|
||||
}
|
||||
|
||||
export const DataGridCellContext =
|
||||
createContext<DataGridCellContextProps<any>>(null);
|
||||
|
||||
interface EditAndSelectState {
|
||||
isEditing: boolean;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
type EditAndSelectAction =
|
||||
| { type: 'EDIT' }
|
||||
| { type: 'CANCEL_EDIT' }
|
||||
| { type: 'SELECT' }
|
||||
| { type: 'DESELECT' };
|
||||
|
||||
function editAndSelectCellReducer(
|
||||
state: EditAndSelectState,
|
||||
action: EditAndSelectAction,
|
||||
): EditAndSelectState {
|
||||
switch (action.type) {
|
||||
case 'EDIT':
|
||||
return { ...state, isEditing: true, isSelected: true };
|
||||
case 'CANCEL_EDIT':
|
||||
return { ...state, isEditing: false };
|
||||
case 'SELECT':
|
||||
return { ...state, isSelected: true };
|
||||
case 'DESELECT':
|
||||
return { ...state, isEditing: false, isSelected: false };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataGridCellProvider<TInput extends HTMLElement>({
|
||||
children,
|
||||
}: PropsWithChildren<unknown>) {
|
||||
const cellRef = useRef<HTMLDivElement>();
|
||||
const inputRef = useRef<TInput>();
|
||||
const [{ isEditing, isSelected }, dispatch] = useReducer(
|
||||
editAndSelectCellReducer,
|
||||
{
|
||||
isEditing: false,
|
||||
isSelected: false,
|
||||
},
|
||||
);
|
||||
|
||||
function focusCell() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
cellRef.current?.focus();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function deselectCell() {
|
||||
dispatch({ type: 'DESELECT' });
|
||||
}
|
||||
|
||||
const focusPrevCell = useCallback(() => {
|
||||
const prevCellAvailable =
|
||||
cellRef.current.previousElementSibling instanceof HTMLElement &&
|
||||
cellRef.current.previousElementSibling.tabIndex > -1;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (prevCellAvailable) {
|
||||
(cellRef.current.previousElementSibling as HTMLElement).focus();
|
||||
deselectCell();
|
||||
}
|
||||
});
|
||||
|
||||
return prevCellAvailable;
|
||||
}, []);
|
||||
|
||||
const focusNextCell = useCallback(() => {
|
||||
const nextCellAvailable =
|
||||
cellRef.current.nextElementSibling instanceof HTMLElement &&
|
||||
cellRef.current.nextElementSibling.tabIndex > -1;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (nextCellAvailable) {
|
||||
(cellRef.current.nextElementSibling as HTMLElement).focus();
|
||||
deselectCell();
|
||||
}
|
||||
});
|
||||
|
||||
return nextCellAvailable;
|
||||
}, []);
|
||||
|
||||
function blurCell() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
cellRef.current?.blur();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function focusInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function blurInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.blur();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function clickInput() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.click();
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function editCell() {
|
||||
dispatch({ type: 'EDIT' });
|
||||
}
|
||||
|
||||
function cancelEditCell() {
|
||||
dispatch({ type: 'CANCEL_EDIT' });
|
||||
}
|
||||
|
||||
function selectCell() {
|
||||
dispatch({ type: 'SELECT' });
|
||||
}
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
focusCell,
|
||||
blurCell,
|
||||
focusInput,
|
||||
blurInput,
|
||||
clickInput,
|
||||
isEditing,
|
||||
isSelected,
|
||||
editCell,
|
||||
cancelEditCell,
|
||||
selectCell,
|
||||
deselectCell,
|
||||
cellRef,
|
||||
inputRef,
|
||||
focusPrevCell,
|
||||
focusNextCell,
|
||||
}),
|
||||
[focusNextCell, focusPrevCell, isEditing, isSelected],
|
||||
);
|
||||
|
||||
return (
|
||||
<DataGridCellContext.Provider value={value}>
|
||||
{children}
|
||||
</DataGridCellContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './DataGridCell';
|
||||
export { default as DataGridCell } from './DataGridCell';
|
||||
export * from './DataGridCellProvider';
|
||||
export { default as DataGridCellProvider } from './DataGridCellProvider';
|
||||
export { default as useDataGridCell } from './useDataGridCell';
|
||||
@@ -1,10 +0,0 @@
|
||||
import { useContext } from 'react';
|
||||
import type { DataGridCellContextProps } from './DataGridCellProvider';
|
||||
import { DataGridCellContext } from './DataGridCellProvider';
|
||||
|
||||
export default function useDataGridCell<TInput extends HTMLElement>() {
|
||||
const context =
|
||||
useContext<DataGridCellContextProps<TInput>>(DataGridCellContext);
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import { createContext } from 'react';
|
||||
|
||||
const DataGridConfigContext = createContext<Partial<UseDataGridReturn>>(null);
|
||||
|
||||
export default DataGridConfigContext;
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import DataGridConfigContext from './DataGridConfigContext';
|
||||
|
||||
export default function DataGridConfigProvider<T extends object = {}>({
|
||||
children,
|
||||
...value
|
||||
}: PropsWithChildren<UseDataGridReturn<T>>) {
|
||||
return (
|
||||
<DataGridConfigContext.Provider
|
||||
value={value as unknown as UseDataGridReturn<{}>}
|
||||
>
|
||||
{children}
|
||||
</DataGridConfigContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as DataGridConfigContext } from './DataGridConfigContext';
|
||||
export { default as DataGridConfigProvider } from './DataGridConfigProvider';
|
||||
export { default as useDataGridConfig } from './useDataGridConfig';
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { UseDataGridReturn } from '@/components/dataGrid/DataGrid';
|
||||
import { useContext } from 'react';
|
||||
import DataGridConfigContext from './DataGridConfigContext';
|
||||
|
||||
export default function useDataGridConfig<T extends object = {}>() {
|
||||
const context = useContext(DataGridConfigContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
`useDataGridConfig must be used within a DataGridConfigContext`,
|
||||
);
|
||||
}
|
||||
|
||||
return context as unknown as UseDataGridReturn<T>;
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import type { TextProps } from '@/components/ui/v2/Text';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { getDateComponents } from '@/utils/getDateComponents';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DataGridDateCellProps<TData extends object>
|
||||
extends CommonDataGridCellProps<TData, string> {
|
||||
/**
|
||||
* Props to be passed to date display.
|
||||
*/
|
||||
dateProps?: TextProps;
|
||||
/**
|
||||
* Props to be passed to time display.
|
||||
*/
|
||||
timeProps?: TextProps;
|
||||
}
|
||||
|
||||
export default function DataGridDateCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { specificType },
|
||||
},
|
||||
dateProps,
|
||||
timeProps,
|
||||
className,
|
||||
}: DataGridDateCellProps<TData>) {
|
||||
const { className: dateClassName, ...restDateProps } = dateProps || {};
|
||||
const { className: timeClassName, ...restTimeProps } = timeProps || {};
|
||||
|
||||
// Note: No date (year-month-day) is saved for time / timetz columns, so we
|
||||
// need to add it manually.
|
||||
const date =
|
||||
optimisticValue && specificType !== 'interval'
|
||||
? new Date(
|
||||
specificType === 'time' || specificType === 'timetz'
|
||||
? `1970-01-01 ${optimisticValue}`
|
||||
: optimisticValue,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const { year, month, day, hour, minute, second } = getDateComponents(date, {
|
||||
adjustTimezone: ['date', 'timetz', 'timestamptz'].includes(specificType),
|
||||
});
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
await onSave(temporaryValue || '');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (event.target instanceof HTMLInputElement && onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={
|
||||
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||
? temporaryValue
|
||||
: ''
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!optimisticValue) {
|
||||
return (
|
||||
<Text className="truncate text-xs" color="secondary">
|
||||
null
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (specificType === 'interval') {
|
||||
return <Text className="truncate text-xs">{optimisticValue}</Text>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={twMerge('grid grid-flow-row', className)}>
|
||||
{specificType !== 'time' && specificType !== 'timetz' && (
|
||||
<Text
|
||||
className={twMerge('truncate text-xs', dateClassName)}
|
||||
{...restDateProps}
|
||||
>
|
||||
{[year, month, day].filter(Boolean).join('-')}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{specificType !== 'date' && (
|
||||
<Text
|
||||
className={twMerge('truncate text-xs', timeClassName)}
|
||||
color={
|
||||
specificType === 'time' || specificType === 'timetz'
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
}
|
||||
{...restTimeProps}
|
||||
>
|
||||
{[hour, minute, second].filter(Boolean).join(':')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridDateCell';
|
||||
export { default as DataGridDateCell } from './DataGridDateCell';
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import clsx from 'clsx';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
|
||||
export type DataGridFrameProps = DetailedHTMLProps<
|
||||
HTMLProps<HTMLDivElement>,
|
||||
HTMLDivElement
|
||||
>;
|
||||
|
||||
export default function DataGridFrame<T extends object>({
|
||||
style,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataGridFrameProps) {
|
||||
const { getTableProps } = useDataGridConfig<T>();
|
||||
const { style: reactTableStyle, ...restTableProps } = getTableProps();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...restTableProps}
|
||||
{...props}
|
||||
className={clsx('min-w-min', className)}
|
||||
style={{ ...reactTableStyle, minWidth: undefined, ...style }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridFrame';
|
||||
export { default as DataGridFrame } from './DataGridFrame';
|
||||
@@ -1,233 +0,0 @@
|
||||
import type { DataGridProps } from '@/components/dataGrid/DataGrid';
|
||||
import { useDataGridConfig } from '@/components/dataGrid/DataGridConfigProvider';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { ArrowDownIcon } from '@/components/ui/v2/icons/ArrowDownIcon';
|
||||
import { ArrowUpIcon } from '@/components/ui/v2/icons/ArrowUpIcon';
|
||||
import { PencilIcon } from '@/components/ui/v2/icons/PencilIcon';
|
||||
import { PlusIcon } from '@/components/ui/v2/icons/PlusIcon';
|
||||
import { TrashIcon } from '@/components/ui/v2/icons/TrashIcon';
|
||||
import type { DataBrowserGridColumn } from '@/features/database/dataGrid/types/dataBrowser';
|
||||
import type { DetailedHTMLProps, HTMLProps } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface HeaderActionProps
|
||||
extends DetailedHTMLProps<HTMLProps<HTMLElement>, HTMLElement> {}
|
||||
|
||||
export interface DataGridHeaderProps<T extends object>
|
||||
extends Omit<
|
||||
DetailedHTMLProps<HTMLProps<HTMLDivElement>, HTMLDivElement>,
|
||||
'children'
|
||||
>,
|
||||
Pick<
|
||||
DataGridProps<T>,
|
||||
'onRemoveColumn' | 'onEditColumn' | 'onInsertColumn'
|
||||
> {
|
||||
/**
|
||||
* Props to be passed to component slots.
|
||||
*/
|
||||
componentsProps?: {
|
||||
/**
|
||||
* Props to be passed to the `Edit Column` header action item.
|
||||
*/
|
||||
editActionProps?: HeaderActionProps;
|
||||
/**
|
||||
* Props to be passed to the `Delete Column` header action item.
|
||||
*/
|
||||
deleteActionProps?: HeaderActionProps;
|
||||
/**
|
||||
* Props to be passed to the `Delete Column` header action item.
|
||||
*/
|
||||
insertActionProps?: HeaderActionProps;
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Get rid of Data Browser related code from here. This component should
|
||||
// be generic and not depend on Data Browser related data types and logic.
|
||||
export default function DataGridHeader<T extends object>({
|
||||
className,
|
||||
onRemoveColumn,
|
||||
onEditColumn,
|
||||
onInsertColumn,
|
||||
componentsProps,
|
||||
...props
|
||||
}: DataGridHeaderProps<T>) {
|
||||
const { flatHeaders, allowSort, allowResize } = useDataGridConfig<T>();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
'sticky top-0 z-30 inline-flex w-full items-center pr-5',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{flatHeaders.map((column: DataBrowserGridColumn<T>) => {
|
||||
const headerProps = column.getHeaderProps({
|
||||
style: { display: 'inline-grid' },
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown.Root
|
||||
sx={{
|
||||
backgroundColor: (theme) =>
|
||||
column.isDisabled
|
||||
? theme.palette.background.default
|
||||
: theme.palette.background.paper,
|
||||
color: 'text.primary',
|
||||
borderColor: 'grey.300',
|
||||
}}
|
||||
className={twMerge(
|
||||
'group relative inline-flex self-stretch overflow-hidden font-display text-xs font-bold focus:outline-none focus-visible:outline-none',
|
||||
'border-b-1 border-r-1',
|
||||
column.id === 'selection' && 'sticky left-0 max-w-2',
|
||||
)}
|
||||
style={{
|
||||
...headerProps.style,
|
||||
maxWidth:
|
||||
column.id === 'selection' ? 32 : headerProps.style?.maxWidth,
|
||||
width:
|
||||
column.id === 'selection' ? '100%' : headerProps.style?.width,
|
||||
zIndex:
|
||||
column.id === 'selection' ? 10 : headerProps.style?.zIndex,
|
||||
position: null,
|
||||
}}
|
||||
key={column.id}
|
||||
>
|
||||
{column.id === 'selection' ? (
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
</span>
|
||||
) : (
|
||||
<Dropdown.Trigger
|
||||
className={twMerge(
|
||||
'focus:outline-none motion-safe:transition-colors',
|
||||
)}
|
||||
disabled={
|
||||
column.isDisabled || (column.disableSortBy && !onRemoveColumn)
|
||||
}
|
||||
hideChevron
|
||||
>
|
||||
<span
|
||||
{...headerProps}
|
||||
className="relative grid w-full grid-flow-col items-center justify-between p-2"
|
||||
>
|
||||
{column.render('Header')}
|
||||
|
||||
{allowSort && (
|
||||
<Box component="span" sx={{ color: 'text.primary' }}>
|
||||
{column.isSorted && !column.isSortedDesc && (
|
||||
<ArrowUpIcon className="h-3 w-3" />
|
||||
)}
|
||||
|
||||
{column.isSorted && column.isSortedDesc && (
|
||||
<ArrowDownIcon className="h-3 w-3" />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{allowResize && !column.disableResizing && (
|
||||
<span
|
||||
{...column.getResizerProps({
|
||||
onClick: (event: Event) => event.stopPropagation(),
|
||||
})}
|
||||
className="absolute -right-0.5 bottom-0 top-0 z-10 h-full w-1.5 group-hover:bg-slate-900 group-hover:bg-opacity-20 group-active:bg-slate-900 group-active:bg-opacity-20 motion-safe:transition-colors"
|
||||
/>
|
||||
)}
|
||||
</Dropdown.Trigger>
|
||||
)}
|
||||
|
||||
<Dropdown.Content
|
||||
menu
|
||||
PaperProps={{ className: 'w-52 mt-1' }}
|
||||
className="p-0"
|
||||
>
|
||||
{onEditColumn && (
|
||||
<Dropdown.Item
|
||||
onClick={() => onEditColumn(column)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
disabled={componentsProps?.editActionProps?.disabled}
|
||||
>
|
||||
<PencilIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Edit Column</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{onEditColumn && <Divider component="li" sx={{ margin: 0 }} />}
|
||||
|
||||
{!column.disableSortBy && (
|
||||
<Dropdown.Item
|
||||
onClick={() => column.toggleSortBy(false)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<ArrowUpIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Sort Ascending</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{!column.disableSortBy && (
|
||||
<Dropdown.Item
|
||||
onClick={() => column.toggleSortBy(true)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
>
|
||||
<ArrowDownIcon
|
||||
className="h-4 w-4"
|
||||
sx={{ color: 'text.secondary' }}
|
||||
/>
|
||||
|
||||
<span>Sort Descending</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
|
||||
{onRemoveColumn && !column.isPrimary && (
|
||||
<Divider component="li" className="my-1" />
|
||||
)}
|
||||
|
||||
{onRemoveColumn && !column.isPrimary && (
|
||||
<Dropdown.Item
|
||||
onClick={() => onRemoveColumn(column)}
|
||||
className="grid grid-flow-col items-center gap-2 p-2 text-sm+ font-medium"
|
||||
disabled={componentsProps?.deleteActionProps?.disabled}
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" sx={{ color: 'error.main' }} />
|
||||
|
||||
<span>Delete Column</span>
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
);
|
||||
})}
|
||||
|
||||
{onInsertColumn && (
|
||||
<Box className="group relative inline-flex w-25 self-stretch overflow-hidden border-b-1 border-r-1 font-display text-xs font-bold focus:outline-none focus-visible:outline-none">
|
||||
<Button
|
||||
onClick={onInsertColumn}
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="h-full w-full rounded-none text-xs hover:shadow-none focus:shadow-none focus:outline-none"
|
||||
aria-label="Insert New Column"
|
||||
disabled={componentsProps?.insertActionProps?.disabled}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" sx={{ color: 'text.disabled' }} />
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridHeader';
|
||||
export { default as DataGridHeader } from './DataGridHeader';
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
|
||||
export type DataGridNumericCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, number>;
|
||||
|
||||
export default function DataGridNumericCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
}: DataGridNumericCellProps<TData>) {
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } =
|
||||
useDataGridCell<HTMLInputElement>();
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
if (typeof temporaryValue === 'number') {
|
||||
await onSave(temporaryValue);
|
||||
} else {
|
||||
await onSave(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
if (onTemporaryValueChange) {
|
||||
if (event.target.value) {
|
||||
onTemporaryValueChange(parseInt(event.target.value, 10));
|
||||
} else {
|
||||
onTemporaryValueChange(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
ref={inputRef}
|
||||
value={
|
||||
temporaryValue !== null && typeof temporaryValue !== 'undefined'
|
||||
? temporaryValue
|
||||
: ''
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (optimisticValue === null || typeof optimisticValue === 'undefined') {
|
||||
return (
|
||||
<Text className="truncate !text-xs" color="disabled">
|
||||
null
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text className="truncate !text-xs">{optimisticValue}</Text>;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridNumericCell';
|
||||
export { default as DataGridNumericCell } from './DataGridNumericCell';
|
||||
@@ -1,91 +0,0 @@
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import type { IconButtonProps } from '@/components/ui/v2/IconButton';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { ChevronLeftIcon } from '@/components/ui/v2/icons/ChevronLeftIcon';
|
||||
import { ChevronRightIcon } from '@/components/ui/v2/icons/ChevronRightIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface DataGridPaginationProps extends BoxProps {
|
||||
/**
|
||||
* Number of pages.
|
||||
*/
|
||||
totalPages: number;
|
||||
/**
|
||||
* Current page.
|
||||
*/
|
||||
currentPage: number;
|
||||
/**
|
||||
* Function to be called when navigating to the previous page.
|
||||
*/
|
||||
onOpenPrevPage: VoidFunction;
|
||||
/**
|
||||
* Function to be called when navigating to the next page.
|
||||
*/
|
||||
onOpenNextPage: VoidFunction;
|
||||
/**
|
||||
* Props to be passed to the next button component.
|
||||
*/
|
||||
nextButtonProps?: IconButtonProps;
|
||||
/**
|
||||
* Props to be passed to the previous button component.
|
||||
*/
|
||||
prevButtonProps?: IconButtonProps;
|
||||
}
|
||||
|
||||
export default function DataGridPagination({
|
||||
className,
|
||||
totalPages,
|
||||
currentPage,
|
||||
onOpenPrevPage,
|
||||
onOpenNextPage,
|
||||
nextButtonProps,
|
||||
prevButtonProps,
|
||||
...props
|
||||
}: DataGridPaginationProps) {
|
||||
return (
|
||||
<Box
|
||||
className={clsx(
|
||||
'grid grid-flow-col items-center justify-around rounded-md border-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={currentPage === 1}
|
||||
onClick={onOpenPrevPage}
|
||||
aria-label="Previous page"
|
||||
{...prevButtonProps}
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
|
||||
<span
|
||||
className={clsx(
|
||||
'mx-1 inline-block font-display font-medium',
|
||||
currentPage > 99 ? 'text-xs' : 'text-sm+',
|
||||
)}
|
||||
>
|
||||
{currentPage}
|
||||
<Text component="span" className="mx-1 inline-block" color="disabled">
|
||||
/
|
||||
</Text>
|
||||
{totalPages}
|
||||
</span>
|
||||
|
||||
<IconButton
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
disabled={currentPage === totalPages}
|
||||
onClick={onOpenNextPage}
|
||||
aria-label="Next page"
|
||||
{...nextButtonProps}
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridPagination';
|
||||
export { default as DataGridPagination } from './DataGridPagination';
|
||||
@@ -1,410 +0,0 @@
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { AudioPreviewIcon } from '@/components/ui/v2/icons/AudioPreviewIcon';
|
||||
import { FilePreviewIcon } from '@/components/ui/v2/icons/FilePreviewIcon';
|
||||
import { PDFPreviewIcon } from '@/components/ui/v2/icons/PDFPreviewIcon';
|
||||
import { VideoPreviewIcon } from '@/components/ui/v2/icons/VideoPreviewIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useAppClient } from '@/features/projects/common/hooks/useAppClient';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import clsx from 'clsx';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useReducer, useState } from 'react';
|
||||
import type { CellProps } from 'react-table';
|
||||
|
||||
export type PreviewProps = {
|
||||
fetchBlob: (
|
||||
init?: RequestInit,
|
||||
size?: { width?: number; height?: number },
|
||||
) => Promise<Blob | null>;
|
||||
mimeType?: string;
|
||||
alt?: string;
|
||||
blob?: Blob;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type DataGridPreviewCellProps<TData extends object> = CellProps<
|
||||
TData,
|
||||
PreviewProps
|
||||
> & {
|
||||
/**
|
||||
* Preview to use when the file is not an image or blob can't be fetched
|
||||
* properly.
|
||||
*
|
||||
* @default null
|
||||
*/
|
||||
fallbackPreview?: ReactNode;
|
||||
};
|
||||
|
||||
function useBlob({
|
||||
fetchBlob,
|
||||
blob,
|
||||
mimeType,
|
||||
}: Pick<PreviewProps, 'fetchBlob' | 'blob' | 'mimeType'>) {
|
||||
const [objectUrl, setObjectUrl] = useState<string>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
// This side-effect fetches the blob of the file from the server and sets the
|
||||
// relevant `objectUrl` state. Abort controller is reponsible for cancelling
|
||||
// the fetch if the component is unmounted.
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
|
||||
async function generateOptimizedObjectUrl() {
|
||||
// todo: it could be more declarative if this function was called with the
|
||||
// actual preview URL here, not pre-generated in useFiles
|
||||
const fetchedBlob = await fetchBlob(
|
||||
{ signal: abortController.signal },
|
||||
mimeType !== 'image/svg+xml' && { width: 80, height: 40 },
|
||||
);
|
||||
|
||||
if (fetchedBlob) {
|
||||
return URL.createObjectURL(fetchedBlob);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function generateObjectUrl() {
|
||||
setLoading(false);
|
||||
setError(undefined);
|
||||
|
||||
if (objectUrl || (mimeType && !mimeType?.startsWith('image'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (blob) {
|
||||
setObjectUrl(URL.createObjectURL(blob));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const generatedObjectUrl = await generateOptimizedObjectUrl();
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setObjectUrl(generatedObjectUrl);
|
||||
}
|
||||
} catch (generateError) {
|
||||
if (!abortController.signal.aborted) {
|
||||
setError(generateError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!abortController.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
generateObjectUrl();
|
||||
|
||||
return () => abortController.abort();
|
||||
}, [blob, fetchBlob, objectUrl, mimeType]);
|
||||
|
||||
return { objectUrl, error, loading };
|
||||
}
|
||||
|
||||
const previewableImages = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/svg+xml',
|
||||
'image/webp',
|
||||
];
|
||||
|
||||
const previewableVideos = [
|
||||
'video/mp4',
|
||||
'video/x-m4v',
|
||||
'video/3gpp',
|
||||
'video/3gpp2',
|
||||
];
|
||||
|
||||
const previewableFileTypes = [
|
||||
...previewableImages,
|
||||
...previewableVideos,
|
||||
'audio/',
|
||||
'application/json',
|
||||
];
|
||||
|
||||
function previewReducer(
|
||||
state: { loading: boolean; error?: Error; data?: string },
|
||||
action:
|
||||
| { type: 'PREVIEW_LOADING' }
|
||||
| { type: 'CLEAR_PREVIEW' }
|
||||
| { type: 'PREVIEW_FETCHED'; payload: string }
|
||||
| { type: 'PREVIEW_ERROR'; payload: Error },
|
||||
): { loading: boolean; error?: Error; data?: string } {
|
||||
switch (action.type) {
|
||||
case 'PREVIEW_LOADING':
|
||||
return { ...state, loading: true, error: undefined, data: undefined };
|
||||
case 'PREVIEW_FETCHED':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
data: action.payload,
|
||||
};
|
||||
case 'PREVIEW_ERROR':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
data: undefined,
|
||||
};
|
||||
case 'CLEAR_PREVIEW':
|
||||
return { ...state, loading: false, error: undefined, data: undefined };
|
||||
default:
|
||||
return { ...state };
|
||||
}
|
||||
}
|
||||
|
||||
export default function DataGridPreviewCell<TData extends object>({
|
||||
value: { fetchBlob, id, mimeType, alt, blob },
|
||||
fallbackPreview = null,
|
||||
}: DataGridPreviewCellProps<TData>) {
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const appClient = useAppClient();
|
||||
const { objectUrl, loading, error } = useBlob({ fetchBlob, blob, mimeType });
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const [
|
||||
{ loading: previewLoading, error: previewError, data: previewUrl },
|
||||
dispatch,
|
||||
] = useReducer(previewReducer, {
|
||||
loading: false,
|
||||
error: undefined,
|
||||
data: undefined,
|
||||
});
|
||||
|
||||
const isPreviewable = previewableFileTypes.some(
|
||||
(type) => mimeType?.startsWith(type) || mimeType === type,
|
||||
);
|
||||
|
||||
const isVideo = mimeType?.startsWith('video');
|
||||
const isAudio = mimeType?.startsWith('audio');
|
||||
const isImage = mimeType?.startsWith('image');
|
||||
const isJson = mimeType === 'application/json';
|
||||
|
||||
async function handleOpenPreview() {
|
||||
if (!mimeType) {
|
||||
dispatch({
|
||||
type: 'PREVIEW_ERROR',
|
||||
payload: new Error('mimeType is not defined.'),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreviewable) {
|
||||
setShowModal(true);
|
||||
dispatch({ type: 'PREVIEW_LOADING' });
|
||||
}
|
||||
|
||||
const { presignedUrl } = await appClient.storage
|
||||
.setAdminSecret(currentProject?.config?.hasura.adminSecret)
|
||||
.getPresignedUrl({ fileId: id });
|
||||
|
||||
if (!presignedUrl) {
|
||||
dispatch({
|
||||
type: 'PREVIEW_ERROR',
|
||||
payload: new Error('Presigned URL could not be fetched.'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isPreviewable) {
|
||||
window.open(presignedUrl.url, '_blank', 'noopener noreferrer');
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'PREVIEW_FETCHED', payload: presignedUrl.url });
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <ActivityIndicator delay={500} className="mx-auto" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Box
|
||||
className="grid w-full grid-flow-col items-center justify-center gap-1 text-center"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<FilePreviewIcon error /> Error
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
wrapperClassName="items-center"
|
||||
showModal={showModal}
|
||||
close={() => setShowModal(false)}
|
||||
afterLeave={() => dispatch({ type: 'CLEAR_PREVIEW' })}
|
||||
className={clsx(
|
||||
previewableImages.includes(mimeType) || isVideo || isAudio
|
||||
? 'mx-12 flex h-screen items-center justify-center'
|
||||
: 'mt-4 inline-block h-near-screen w-full px-12',
|
||||
)}
|
||||
>
|
||||
<Box
|
||||
className={clsx(
|
||||
!isJson && 'bg-checker-pattern',
|
||||
'relative mx-auto flex overflow-hidden rounded-md',
|
||||
)}
|
||||
sx={{
|
||||
backgroundColor: isJson && 'background.default',
|
||||
color: 'text.primary',
|
||||
}}
|
||||
>
|
||||
{!previewLoading && (
|
||||
<IconButton
|
||||
aria-label="Close"
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
className="absolute right-2 top-2 z-50 p-2"
|
||||
sx={{
|
||||
[`&:hover, &:active, &:focus`]: {
|
||||
backgroundColor: (theme) => {
|
||||
if (isAudio || isVideo || isJson) {
|
||||
return 'common.black';
|
||||
}
|
||||
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'grey.800'
|
||||
: 'grey.200';
|
||||
},
|
||||
},
|
||||
}}
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<XIcon
|
||||
className="h-5 w-5"
|
||||
sx={{
|
||||
color: (theme) => {
|
||||
if (isAudio || isVideo || isJson) {
|
||||
return 'common.white';
|
||||
}
|
||||
|
||||
return theme.palette.mode === 'dark'
|
||||
? 'grey.100'
|
||||
: 'grey.700';
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
|
||||
{previewLoading && !previewUrl && (
|
||||
<ActivityIndicator
|
||||
delay={500}
|
||||
className="mx-auto"
|
||||
label="Loading preview..."
|
||||
/>
|
||||
)}
|
||||
|
||||
{previewError && (
|
||||
<Box
|
||||
className="px-6 py-3.5 pr-12 text-start font-medium"
|
||||
sx={{ color: 'error.main' }}
|
||||
>
|
||||
<p>Error: Preview can't be loaded.</p>
|
||||
|
||||
<p>{previewError.message}</p>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{previewUrl && isImage && (
|
||||
<picture className="h-auto max-h-near-screen min-h-38 min-w-38">
|
||||
<source srcSet={previewUrl} type={mimeType} />
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={alt}
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
|
||||
{previewUrl && isVideo && (
|
||||
<video
|
||||
autoPlay
|
||||
controls
|
||||
className="h-auto max-h-near-screen w-full bg-black"
|
||||
>
|
||||
<track kind="captions" />
|
||||
<source src={previewUrl} type={mimeType} />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
)}
|
||||
|
||||
{previewUrl && isAudio && (
|
||||
<audio autoPlay controls className="h-28 bg-black">
|
||||
<track kind="captions" />
|
||||
<source src={previewUrl} type={mimeType} />
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
)}
|
||||
|
||||
{!previewLoading &&
|
||||
previewUrl &&
|
||||
!previewableImages.includes(mimeType) &&
|
||||
!isVideo &&
|
||||
!isAudio && (
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
className="h-near-screen w-full"
|
||||
title="File preview"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
|
||||
<div className="flex h-full w-full justify-center">
|
||||
{previewableImages.includes(mimeType) && objectUrl && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={alt}
|
||||
onClick={handleOpenPreview}
|
||||
className="mx-auto h-full"
|
||||
>
|
||||
<picture className="h-full w-20">
|
||||
<source srcSet={objectUrl} type={mimeType} />
|
||||
<img
|
||||
src={objectUrl}
|
||||
alt={alt}
|
||||
className="h-full w-full object-scale-down"
|
||||
/>
|
||||
</picture>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{(!previewableImages.includes(mimeType) || !objectUrl) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenPreview}
|
||||
aria-label={alt}
|
||||
className="grid h-full w-full items-center justify-center self-center"
|
||||
>
|
||||
{isVideo && <VideoPreviewIcon className="h-5 w-5" />}
|
||||
|
||||
{isAudio && <AudioPreviewIcon className="h-5 w-5" />}
|
||||
|
||||
{mimeType === 'application/pdf' && (
|
||||
<PDFPreviewIcon className="h-5 w-5" />
|
||||
)}
|
||||
|
||||
{!isVideo &&
|
||||
!isAudio &&
|
||||
mimeType !== 'application/pdf' &&
|
||||
fallbackPreview}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridPreviewCell';
|
||||
export { default as DataGridPreviewCell } from './DataGridPreviewCell';
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { CommonDataGridCellProps } from '@/components/dataGrid/DataGridCell';
|
||||
import { useDataGridCell } from '@/components/dataGrid/DataGridCell';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { copy } from '@/utils/copy';
|
||||
import type { ChangeEvent, KeyboardEvent, Ref } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export type DataGridTextCellProps<TData extends object> =
|
||||
CommonDataGridCellProps<TData, string>;
|
||||
|
||||
export default function DataGridTextCell<TData extends object>({
|
||||
onSave,
|
||||
optimisticValue,
|
||||
temporaryValue,
|
||||
onTemporaryValueChange,
|
||||
cell: {
|
||||
column: { isCopiable, specificType },
|
||||
},
|
||||
}: DataGridTextCellProps<TData>) {
|
||||
const isMultiline =
|
||||
specificType === 'text' ||
|
||||
specificType === 'bpchar' ||
|
||||
specificType === 'varchar' ||
|
||||
specificType === 'json' ||
|
||||
specificType === 'jsonb';
|
||||
|
||||
const normalizedOptimisticValue =
|
||||
optimisticValue !== null && typeof optimisticValue === 'object'
|
||||
? optimisticValue
|
||||
: (String(optimisticValue) || '').replace(/(\\n)+/gi, ' ');
|
||||
|
||||
const normalizedTemporaryValue =
|
||||
temporaryValue !== null && typeof temporaryValue === 'object'
|
||||
? JSON.stringify(temporaryValue)
|
||||
: temporaryValue;
|
||||
|
||||
const { inputRef, focusCell, isEditing, cancelEditCell } = useDataGridCell<
|
||||
HTMLInputElement | HTMLTextAreaElement
|
||||
>();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && isMultiline) {
|
||||
const textArea = inputRef.current as HTMLTextAreaElement;
|
||||
|
||||
textArea.setSelectionRange(textArea.value.length, textArea.value.length);
|
||||
}
|
||||
}, [inputRef, isEditing, isMultiline]);
|
||||
|
||||
async function handleSave() {
|
||||
if (onSave) {
|
||||
await onSave((normalizedTemporaryValue || '').replace(/\n/gi, `\\n`));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInputKeyDown(event: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTextAreaKeyDown(
|
||||
event: KeyboardEvent<HTMLTextAreaElement>,
|
||||
) {
|
||||
if (
|
||||
event.key === 'ArrowLeft' ||
|
||||
event.key === 'ArrowRight' ||
|
||||
event.key === 'ArrowUp' ||
|
||||
event.key === 'ArrowDown' ||
|
||||
event.key === 'Backspace'
|
||||
) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
// Saving content Enter / CTRL + Enter / CMD + Enter (macOS) - but not on
|
||||
// Shift + Enter
|
||||
if (
|
||||
(!event.shiftKey && event.key === 'Enter') ||
|
||||
(event.ctrlKey && event.key === 'Enter') ||
|
||||
(event.metaKey && event.key === 'Enter')
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
await handleSave();
|
||||
await focusCell();
|
||||
cancelEditCell();
|
||||
}
|
||||
|
||||
if (event.key === 'Tab') {
|
||||
await handleSave();
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(
|
||||
event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||||
) {
|
||||
if (onTemporaryValueChange) {
|
||||
onTemporaryValueChange(event.target.value);
|
||||
}
|
||||
}
|
||||
|
||||
if (isEditing && isMultiline) {
|
||||
return (
|
||||
<Input
|
||||
multiline
|
||||
ref={inputRef as Ref<HTMLInputElement>}
|
||||
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleTextAreaKeyDown}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full min-h-38"
|
||||
rows={5}
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef as Ref<HTMLInputElement>}
|
||||
value={(normalizedTemporaryValue || '').replace(/\\n/gi, `\n`)}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
fullWidth
|
||||
className="absolute top-0 z-10 -mx-0.5 h-full place-content-stretch"
|
||||
sx={{
|
||||
[`&.${inputClasses.focused}`]: {
|
||||
boxShadow: `inset 0 0 0 1.5px rgba(0, 82, 205, 1)`,
|
||||
borderColor: 'transparent !important',
|
||||
borderRadius: 0,
|
||||
backgroundColor: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? `${theme.palette.secondary[100]} !important`
|
||||
: `${theme.palette.common.white} !important`,
|
||||
},
|
||||
[`& .${inputClasses.input}`]: {
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
}}
|
||||
slotProps={{
|
||||
inputWrapper: { className: 'h-full' },
|
||||
input: { className: 'h-full' },
|
||||
inputRoot: {
|
||||
className:
|
||||
'resize-none outline-none focus:outline-none !text-xs focus:ring-0',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!optimisticValue) {
|
||||
return (
|
||||
<Text className="truncate !text-xs" color="secondary">
|
||||
{optimisticValue === '' ? 'empty' : 'null'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopiable) {
|
||||
return (
|
||||
<div className="grid grid-flow-col items-center justify-start gap-1">
|
||||
<Button
|
||||
variant="borderless"
|
||||
color="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
|
||||
const copiableValue =
|
||||
typeof optimisticValue === 'object'
|
||||
? JSON.stringify(optimisticValue)
|
||||
: String(optimisticValue).replace(/\\n/gi, '\n');
|
||||
|
||||
copy(copiableValue, 'Value');
|
||||
}}
|
||||
className="-ml-px min-w-0 p-0"
|
||||
aria-label="Copy value"
|
||||
sx={{
|
||||
color: (theme) =>
|
||||
theme.palette.mode === 'dark'
|
||||
? 'text.secondary'
|
||||
: 'text.disabled',
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Text className="truncate text-xs">
|
||||
{typeof normalizedOptimisticValue === 'object'
|
||||
? JSON.stringify(normalizedOptimisticValue)
|
||||
: normalizedOptimisticValue}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text className="truncate text-xs">
|
||||
{typeof normalizedOptimisticValue === 'object'
|
||||
? JSON.stringify(normalizedOptimisticValue)
|
||||
: normalizedOptimisticValue}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DataGridTextCell';
|
||||
export { default as DataGridTextCell } from './DataGridTextCell';
|
||||
@@ -1,46 +0,0 @@
|
||||
import { AISidebar } from '@/components/layout/AISidebar';
|
||||
import type { ProjectLayoutProps } from '@/components/layout/ProjectLayout';
|
||||
import { ProjectLayout } from '@/components/layout/ProjectLayout';
|
||||
import type { SettingsSidebarProps } from '@/components/layout/SettingsSidebar';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AILayoutProps extends ProjectLayoutProps {
|
||||
/**
|
||||
* Props passed to the sidebar component.
|
||||
*/
|
||||
sidebarProps?: SettingsSidebarProps;
|
||||
}
|
||||
|
||||
export default function AILayout({
|
||||
children,
|
||||
mainContainerProps: {
|
||||
className: mainContainerClassName,
|
||||
...mainContainerProps
|
||||
} = {},
|
||||
sidebarProps: { className: sidebarClassName, ...sidebarProps } = {},
|
||||
...props
|
||||
}: AILayoutProps) {
|
||||
return (
|
||||
<ProjectLayout
|
||||
mainContainerProps={{
|
||||
className: twMerge('flex h-full', mainContainerClassName),
|
||||
...mainContainerProps,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<AISidebar
|
||||
className={twMerge('w-full max-w-sidebar', sidebarClassName)}
|
||||
{...sidebarProps}
|
||||
/>
|
||||
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-scroll overflow-x-hidden"
|
||||
>
|
||||
<RetryableErrorBoundary>{children}</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</ProjectLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './AILayout';
|
||||
export { default as SettingsLayout } from './AILayout';
|
||||
@@ -1,143 +0,0 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface AISidebarProps extends Omit<BoxProps, 'children'> {}
|
||||
|
||||
interface AINavLinkProps extends ListItemButtonProps {
|
||||
/**
|
||||
* Link to navigate to.
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* Determines whether or not the link should be active if href matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
function AINavLink({ exact = true, href, children, ...props }: AINavLinkProps) {
|
||||
const router = useRouter();
|
||||
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}/ai`;
|
||||
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
|
||||
|
||||
const active = exact
|
||||
? router.asPath === finalUrl
|
||||
: router.asPath.startsWith(finalUrl);
|
||||
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
dense
|
||||
href={finalUrl}
|
||||
component={NavLink}
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Text>{children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AISidebar({ className, ...props }: AISidebarProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function closeSidebarWhenEscapeIsPressed(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}
|
||||
|
||||
return () =>
|
||||
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}, []);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop
|
||||
open={expanded}
|
||||
className="absolute bottom-0 left-0 right-0 top-0 z-[34] md:hidden"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Close sidebar overlay"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] h-full w-full overflow-auto border-r-1 px-2 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:py-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<nav aria-label="Settings navigation">
|
||||
<List className="grid gap-2">
|
||||
<AINavLink
|
||||
href="/auto-embeddings"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Auto-Embeddings
|
||||
</AINavLink>
|
||||
|
||||
<AINavLink href="/assistants" exact={false} onClick={handleSelect}>
|
||||
Assistants
|
||||
</AINavLink>
|
||||
</List>
|
||||
</nav>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
|
||||
onClick={toggleExpanded}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
src="/assets/table.svg"
|
||||
alt="A monochrome table"
|
||||
/>
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './AISidebar';
|
||||
export { default as AISidebar } from './AISidebar';
|
||||
@@ -1,4 +1,3 @@
|
||||
import { InviteNotification } from '@/components/common/InviteNotification';
|
||||
import type { BaseLayoutProps } from '@/components/layout/BaseLayout';
|
||||
import { BaseLayout } from '@/components/layout/BaseLayout';
|
||||
import { Container } from '@/components/layout/Container';
|
||||
@@ -10,14 +9,14 @@ import { RetryableErrorBoundary } from '@/components/presentational/RetryableErr
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
|
||||
import { useMediaQuery } from '@/components/common/useMediaQuery';
|
||||
import PinnedMainNav from '@/components/layout/MainNav/PinnedMainNav';
|
||||
import { OrgStatus } from '@/features/orgs/components/OrgStatus';
|
||||
import { useIsHealthy } from '@/features/orgs/projects/common/hooks/useIsHealthy';
|
||||
import { useNotFoundRedirect } from '@/features/projects/common/hooks/useNotFoundRedirect';
|
||||
import { useNotFoundRedirect } from '@/features/orgs/projects/common/hooks/useNotFoundRedirect';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
@@ -98,7 +97,7 @@ export default function AuthenticatedLayout({
|
||||
<HighlightedText className="font-mono">nhost up</HighlightedText>?
|
||||
Please refer to the{' '}
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/cli"
|
||||
href="https://docs.nhost.io/platform/cli/local-development"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
@@ -146,8 +145,6 @@ export default function AuthenticatedLayout({
|
||||
{children}
|
||||
</div>
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
<InviteNotification />
|
||||
</div>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface BreadcrumbsProps extends BoxProps {}
|
||||
|
||||
export default function Breadcrumbs({ className, ...props }: BreadcrumbsProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { currentWorkspace, currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'grid grid-flow-col items-center gap-3 text-sm font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Text color="disabled">/</Text>
|
||||
|
||||
<Text className="truncate text-[13px] sm:text-sm">local</Text>
|
||||
|
||||
<Text color="disabled">/</Text>
|
||||
|
||||
<NavLink
|
||||
href="/local/local"
|
||||
className="truncate text-[13px] hover:underline sm:text-sm"
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
local
|
||||
</NavLink>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'grid grid-flow-col items-center gap-3 text-sm font-medium',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentWorkspace && (
|
||||
<>
|
||||
<Text color="disabled">/</Text>
|
||||
|
||||
<NavLink
|
||||
href={`/${currentWorkspace.slug}`}
|
||||
className="truncate text-[13px] hover:underline sm:text-sm"
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
{currentWorkspace.name}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentProject && (
|
||||
<>
|
||||
<Text color="disabled">/</Text>
|
||||
|
||||
<NavLink
|
||||
href={`/${currentWorkspace.slug}/${currentProject.slug}`}
|
||||
className="truncate text-[13px] hover:underline sm:text-sm"
|
||||
sx={{ color: 'text.primary' }}
|
||||
>
|
||||
{currentProject.name}
|
||||
</NavLink>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './Breadcrumbs';
|
||||
export { default as Breadcrumbs } from './Breadcrumbs';
|
||||
@@ -1,90 +0,0 @@
|
||||
import type { IconLinkProps } from '@/components/common/IconLink';
|
||||
import { IconLink } from '@/components/common/IconLink';
|
||||
import { Nav } from '@/components/presentational/Nav';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { useProjectRoutes } from '@/features/projects/common/hooks/useProjectRoutes';
|
||||
import { useRouter } from 'next/router';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface DesktopNavProps extends Omit<BoxProps, 'children'> {}
|
||||
|
||||
interface DesktopNavLinkProps extends IconLinkProps {
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* Path of the link.
|
||||
*/
|
||||
path?: string;
|
||||
}
|
||||
|
||||
function DesktopNavLink({
|
||||
exact = true,
|
||||
href,
|
||||
path,
|
||||
...props
|
||||
}: DesktopNavLinkProps) {
|
||||
const router = useRouter();
|
||||
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}`;
|
||||
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
|
||||
const finalRelativePath =
|
||||
path && path !== '/' ? `${baseUrl}${path}` : baseUrl;
|
||||
|
||||
const active = exact
|
||||
? router.asPath === finalUrl
|
||||
: router.asPath.startsWith(finalRelativePath);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<IconLink {...props} href={finalUrl} active={props.active || active} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DesktopNav({ className, ...props }: DesktopNavProps) {
|
||||
const { allRoutes } = useProjectRoutes();
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={twMerge(
|
||||
'w-20 content-start overflow-hidden overflow-y-auto border-r-1 px-1 pb-10',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Nav
|
||||
aria-label="Main navigation"
|
||||
className="w-full"
|
||||
flow="row"
|
||||
listProps={{ className: 'gap-2 justify-center py-2' }}
|
||||
>
|
||||
{allRoutes.map(
|
||||
({
|
||||
relativePath,
|
||||
relativeMainPath,
|
||||
label,
|
||||
icon,
|
||||
exact,
|
||||
disabled,
|
||||
}) => (
|
||||
<DesktopNavLink
|
||||
href={relativePath}
|
||||
path={relativeMainPath || relativePath}
|
||||
exact={exact}
|
||||
icon={icon}
|
||||
key={relativePath}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</DesktopNavLink>
|
||||
),
|
||||
)}
|
||||
</Nav>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './DesktopNav';
|
||||
export { default as DesktopNav } from './DesktopNav';
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
|
||||
type BreadCrumbComboBoxItem<T> = {
|
||||
label: string | ReactNode;
|
||||
value: string | T;
|
||||
};
|
||||
|
||||
interface BreadCrumbComboBoxProps<T> {
|
||||
selectedValue?: T;
|
||||
options: BreadCrumbComboBoxItem<T>[];
|
||||
renderItem?: (item: T) => ReactNode;
|
||||
onChange?: (item: BreadCrumbComboBoxItem<T>) => void;
|
||||
filter?: (value: string, search: string) => number;
|
||||
}
|
||||
|
||||
export default function BreadCrumbComboBox<T>({
|
||||
selectedValue,
|
||||
options,
|
||||
renderItem,
|
||||
onChange,
|
||||
filter,
|
||||
}: BreadCrumbComboBoxProps<T>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedItem, setSelectedItem] =
|
||||
useState<BreadCrumbComboBoxItem<T> | null>(
|
||||
options.find((option) => option.value === selectedValue) || null,
|
||||
);
|
||||
|
||||
const renderSelectedItem = (item: BreadCrumbComboBoxItem<T>) => {
|
||||
if (typeof item.value === 'string') {
|
||||
return typeof item.label === 'string' ? (
|
||||
<span className="text-foreground">{item.label}</span>
|
||||
) : (
|
||||
item.label
|
||||
);
|
||||
}
|
||||
return renderItem ? renderItem(item.value) : null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="justify-start text-foreground"
|
||||
>
|
||||
<div className="flex flex-row items-center justify-center gap-1">
|
||||
{selectedItem && renderSelectedItem(selectedItem)}
|
||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" side="bottom" align="start">
|
||||
<Command filter={filter}>
|
||||
<CommandInput placeholder="Search..." autoFocus />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option, index) => (
|
||||
<CommandItem
|
||||
key={`${
|
||||
typeof option.value === 'string' ? option.value : index
|
||||
}`}
|
||||
value={
|
||||
typeof option.value === 'string' ? option.value : `${index}`
|
||||
}
|
||||
onSelect={() => {
|
||||
setSelectedItem(option);
|
||||
setOpen(false);
|
||||
onChange?.(option);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedItem?.value === option.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
{typeof option.value === 'string'
|
||||
? option.label
|
||||
: renderItem && renderItem(option.value)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -17,8 +17,7 @@ import ProjectSettingsPagesComboBox from './ProjectSettingsPagesComboBox';
|
||||
export default function BreadcrumbNav() {
|
||||
const { query, asPath, route } = useRouter();
|
||||
|
||||
// Extract orgSlug and appSubdomain from router.query
|
||||
const { appSubdomain, workspaceSlug } = query;
|
||||
const { appSubdomain } = query;
|
||||
|
||||
// Extract path segments from the URL
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
@@ -27,8 +26,7 @@ export default function BreadcrumbNav() {
|
||||
const projectPage = pathSegments[3] || null;
|
||||
const isSettingsPage = pathSegments[5] === 'settings';
|
||||
|
||||
const showBreadcrumbs =
|
||||
!workspaceSlug && !['/', '/orgs/verify'].includes(route);
|
||||
const showBreadcrumbs = !['/', '/orgs/verify'].includes(route);
|
||||
|
||||
return (
|
||||
<Breadcrumb className="mt-2 flex w-full flex-row flex-nowrap overflow-x-auto lg:mt-0 lg:overflow-visible">
|
||||
|
||||
@@ -7,14 +7,11 @@ import { Logo } from '@/components/presentational/Logo';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { GraphiteIcon } from '@/components/ui/v2/icons/GraphiteIcon';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { DevAssistant as WorkspaceProjectDevAssistant } from '@/features/ai/DevAssistant';
|
||||
import { AnnouncementsTray } from '@/features/orgs/components/members/components/AnnouncementsTray';
|
||||
import { NotificationsTray } from '@/features/orgs/components/members/components/NotificationsTray';
|
||||
import { DevAssistant } from '@/features/orgs/projects/ai/DevAssistant';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import type { DetailedHTMLProps, HTMLProps, PropsWithoutRef } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
@@ -30,12 +27,10 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { openDrawer } = useDialog();
|
||||
const { project } = useProject();
|
||||
const { currentProject: workspaceProject } = useCurrentWorkspaceAndProject();
|
||||
const { currentOrg: org } = useOrgs();
|
||||
|
||||
const openDevAssistant = () => {
|
||||
// The dev assistant can be only answer questions related to a particular project
|
||||
if (!project && !workspaceProject) {
|
||||
if (!project) {
|
||||
toast.error('You need to be inside a project to open the Assistant', {
|
||||
style: getToastStyleProps().style,
|
||||
...getToastStyleProps().error,
|
||||
@@ -44,17 +39,10 @@ export default function Header({ className, ...props }: HeaderProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (org && project) {
|
||||
openDrawer({
|
||||
title: <GraphiteIcon />,
|
||||
component: <DevAssistant />,
|
||||
});
|
||||
} else {
|
||||
openDrawer({
|
||||
title: <GraphiteIcon />,
|
||||
component: <WorkspaceProjectDevAssistant />,
|
||||
});
|
||||
}
|
||||
openDrawer({
|
||||
title: <GraphiteIcon />,
|
||||
component: <DevAssistant />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from '@/components/ui/v3/command';
|
||||
import {
|
||||
Popover,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useWorkspaces } from '@/features/orgs/projects/hooks/useWorkspaces';
|
||||
import { useSSRLocalStorage } from '@/hooks/useSSRLocalStorage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
@@ -27,53 +25,39 @@ type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
plan: string;
|
||||
type: 'organization' | 'workspace';
|
||||
};
|
||||
|
||||
export default function OrgsComboBox() {
|
||||
const { orgs } = useOrgs();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { workspaces } = useWorkspaces();
|
||||
const [, setLastSlug] = useSSRLocalStorage('slug', null);
|
||||
|
||||
const {
|
||||
query: { orgSlug, workspaceSlug },
|
||||
query: { orgSlug },
|
||||
push,
|
||||
} = useRouter();
|
||||
|
||||
const selectedOrgFromUrl =
|
||||
Boolean(orgSlug) && orgs.find((item) => item.slug === orgSlug);
|
||||
const selectedWorkspaceFromUrl =
|
||||
Boolean(workspaceSlug) &&
|
||||
workspaces.find((item) => item.slug === workspaceSlug);
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Option | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedItemFromUrl = selectedOrgFromUrl || selectedWorkspaceFromUrl;
|
||||
const selectedItemFromUrl = selectedOrgFromUrl;
|
||||
|
||||
if (selectedItemFromUrl) {
|
||||
setSelectedItem({
|
||||
label: selectedItemFromUrl.name,
|
||||
value: selectedItemFromUrl.slug,
|
||||
plan: selectedOrgFromUrl ? selectedOrgFromUrl.plan.name : 'Legacy',
|
||||
type: selectedOrgFromUrl ? 'organization' : 'workspace',
|
||||
});
|
||||
}
|
||||
}, [selectedOrgFromUrl, selectedWorkspaceFromUrl]);
|
||||
}, [selectedOrgFromUrl]);
|
||||
|
||||
const orgsOptions: Option[] = orgs.map((org) => ({
|
||||
label: org.name,
|
||||
value: org.slug,
|
||||
plan: org.plan.name,
|
||||
type: 'organization',
|
||||
}));
|
||||
|
||||
const workspacesOptions: Option[] = workspaces.map((workspace) => ({
|
||||
label: workspace.name,
|
||||
value: workspace.slug,
|
||||
plan: 'Legacy',
|
||||
type: 'workspace',
|
||||
}));
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -113,7 +97,7 @@ export default function OrgsComboBox() {
|
||||
{renderBadge(selectedItem.plan)}
|
||||
</div>
|
||||
) : (
|
||||
'Select organization / workspace'
|
||||
'Select organization'
|
||||
)}
|
||||
<ChevronsUpDown className="h-5 w-5 text-muted-foreground" />
|
||||
</Button>
|
||||
@@ -137,11 +121,7 @@ export default function OrgsComboBox() {
|
||||
// persist last slug in local storage
|
||||
setLastSlug(option.value);
|
||||
|
||||
if (option.type === 'organization') {
|
||||
push(`/orgs/${option.value}/projects`);
|
||||
} else {
|
||||
push(`/${option.value}`);
|
||||
}
|
||||
push(`/orgs/${option.value}/projects`);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
@@ -157,47 +137,6 @@ export default function OrgsComboBox() {
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
|
||||
{workspaces.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
|
||||
<CommandGroup heading="Workspaces">
|
||||
{workspacesOptions.map((option) => (
|
||||
<CommandItem
|
||||
keywords={[option.label]}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className="flex items-center text-foreground dark:hover:bg-muted"
|
||||
onSelect={() => {
|
||||
setSelectedItem(option);
|
||||
setOpen(false);
|
||||
|
||||
// persist last slug in local storage
|
||||
setLastSlug(option.value);
|
||||
|
||||
if (option.type === 'organization') {
|
||||
push(`/orgs/${option.value}/projects`);
|
||||
} else {
|
||||
push(`/${option.value}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedItem?.value === option.value
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<span className="w-full truncate">{option.label}</span>
|
||||
{renderBadge(option.plan)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Separator } from '@/components/ui/v3/separator';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@@ -8,13 +7,11 @@ import {
|
||||
SheetTitle,
|
||||
} from '@/components/ui/v3/sheet';
|
||||
import CreateOrgDialog from '@/features/orgs/components/CreateOrgFormDialog/CreateOrgFormDialog';
|
||||
import { useWorkspaces } from '@/features/orgs/projects/hooks/useWorkspaces';
|
||||
import { Menu, Pin, PinOff, X } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import NavTree from './NavTree';
|
||||
import { useTreeNavState } from './TreeNavStateContext';
|
||||
import WorkspacesNavTree from './WorkspacesNavTree';
|
||||
|
||||
interface MainNavProps {
|
||||
container: HTMLElement;
|
||||
@@ -22,7 +19,6 @@ interface MainNavProps {
|
||||
|
||||
export default function MainNav({ container }: MainNavProps) {
|
||||
const { asPath } = useRouter();
|
||||
const { workspaces } = useWorkspaces();
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const { open, setOpen, mainNavPinned, setMainNavPinned } = useTreeNavState();
|
||||
|
||||
@@ -95,14 +91,6 @@ export default function MainNav({ container }: MainNavProps) {
|
||||
<NavTree />
|
||||
<CreateOrgDialog />
|
||||
</div>
|
||||
{workspaces.length > 0 && (
|
||||
<>
|
||||
<Separator className="mx-auto my-2" />
|
||||
<div className="px-2">
|
||||
<WorkspacesNavTree />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import NavTree from '@/components/layout/MainNav/NavTree';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import { Separator } from '@/components/ui/v3/separator';
|
||||
import CreateOrgDialog from '@/features/orgs/components/CreateOrgFormDialog/CreateOrgFormDialog';
|
||||
import { useWorkspaces } from '@/features/orgs/projects/hooks/useWorkspaces';
|
||||
import { Pin, PinOff } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTreeNavState } from './TreeNavStateContext';
|
||||
import WorkspacesNavTree from './WorkspacesNavTree';
|
||||
|
||||
export default function PinnedMainNav() {
|
||||
const {
|
||||
asPath,
|
||||
query: { workspaceSlug, orgSlug },
|
||||
query: { orgSlug },
|
||||
} = useRouter();
|
||||
|
||||
const scrollContainerRef = useRef();
|
||||
const { workspaces } = useWorkspaces();
|
||||
const { mainNavPinned, setMainNavPinned } = useTreeNavState();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -48,7 +44,7 @@ export default function PinnedMainNav() {
|
||||
};
|
||||
}, [asPath]);
|
||||
|
||||
if (!orgSlug && !workspaceSlug) {
|
||||
if (!orgSlug) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -75,14 +71,6 @@ export default function PinnedMainNav() {
|
||||
<NavTree />
|
||||
<CreateOrgDialog />
|
||||
</div>
|
||||
{workspaces.length > 0 && (
|
||||
<>
|
||||
<Separator className="mx-auto my-2" />
|
||||
<div className="px-2">
|
||||
<WorkspacesNavTree />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useNavTreeStateFromURL } from '@/features/orgs/projects/hooks/useNavTreeStateFromURL';
|
||||
import { useWorkspacesNavTreeStateFromURL } from '@/features/orgs/projects/hooks/useWorkspacesNavTreeStateFromURL';
|
||||
import { useSSRLocalStorage } from '@/hooks/useSSRLocalStorage';
|
||||
import {
|
||||
createContext,
|
||||
@@ -21,10 +20,6 @@ interface TreeNavStateContextType {
|
||||
setOrgsTreeViewState: Dispatch<
|
||||
SetStateAction<IndividualTreeViewState<never>>
|
||||
>;
|
||||
workspacesTreeViewState: IndividualTreeViewState<never>;
|
||||
setWorkspacesTreeViewState: Dispatch<
|
||||
SetStateAction<IndividualTreeViewState<never>>
|
||||
>;
|
||||
setMainNavPinned: (value: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -71,10 +66,6 @@ function TreeNavStateProvider({ children }: TreeNavProviderProps) {
|
||||
);
|
||||
const orgsTreeViewState = useSyncedTreeViewState(useNavTreeStateFromURL);
|
||||
|
||||
const workspacesTreeViewState = useSyncedTreeViewState(
|
||||
useWorkspacesNavTreeStateFromURL,
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
@@ -83,8 +74,6 @@ function TreeNavStateProvider({ children }: TreeNavProviderProps) {
|
||||
setMainNavPinned,
|
||||
orgsTreeViewState: orgsTreeViewState.state,
|
||||
setOrgsTreeViewState: orgsTreeViewState.setState,
|
||||
workspacesTreeViewState: workspacesTreeViewState.state,
|
||||
setWorkspacesTreeViewState: workspacesTreeViewState.setState,
|
||||
}),
|
||||
[
|
||||
open,
|
||||
@@ -93,8 +82,6 @@ function TreeNavStateProvider({ children }: TreeNavProviderProps) {
|
||||
setMainNavPinned,
|
||||
orgsTreeViewState.state,
|
||||
orgsTreeViewState.setState,
|
||||
workspacesTreeViewState.state,
|
||||
workspacesTreeViewState.setState,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,495 +0,0 @@
|
||||
import { AIIcon } from '@/components/ui/v2/icons/AIIcon';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { CloudIcon } from '@/components/ui/v2/icons/CloudIcon';
|
||||
import { DatabaseIcon } from '@/components/ui/v2/icons/DatabaseIcon';
|
||||
import { FileTextIcon } from '@/components/ui/v2/icons/FileTextIcon';
|
||||
import { GaugeIcon } from '@/components/ui/v2/icons/GaugeIcon';
|
||||
import { GraphQLIcon } from '@/components/ui/v2/icons/GraphQLIcon';
|
||||
import { HasuraIcon } from '@/components/ui/v2/icons/HasuraIcon';
|
||||
import { HomeIcon } from '@/components/ui/v2/icons/HomeIcon';
|
||||
import { RocketIcon } from '@/components/ui/v2/icons/RocketIcon';
|
||||
import { ServicesIcon } from '@/components/ui/v2/icons/ServicesIcon';
|
||||
import { StorageIcon } from '@/components/ui/v2/icons/StorageIcon';
|
||||
import { UserIcon } from '@/components/ui/v2/icons/UserIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from '@/components/ui/v3/hover-card';
|
||||
import { useWorkspaces } from '@/features/orgs/projects/hooks/useWorkspaces';
|
||||
import { type Workspace } from '@/features/orgs/projects/hooks/useWorkspaces/useWorkspaces';
|
||||
import { useSSRLocalStorage } from '@/hooks/useSSRLocalStorage';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Box, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import NextLink from 'next/link';
|
||||
import { type ReactElement } from 'react';
|
||||
|
||||
import {
|
||||
ControlledTreeEnvironment,
|
||||
Tree,
|
||||
type TreeItem,
|
||||
type TreeItemIndex,
|
||||
} from 'react-complex-tree';
|
||||
import { useTreeNavState } from './TreeNavStateContext';
|
||||
|
||||
const projectPages = [
|
||||
{
|
||||
name: 'Overview',
|
||||
icon: <HomeIcon className="h-4 w-4" />,
|
||||
route: '',
|
||||
slug: 'overview',
|
||||
},
|
||||
{
|
||||
name: 'Database',
|
||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||
route: 'database/browser/default',
|
||||
slug: 'database',
|
||||
},
|
||||
{
|
||||
name: 'GraphQL',
|
||||
icon: <GraphQLIcon className="h-4 w-4" />,
|
||||
route: 'graphql',
|
||||
slug: 'graphql',
|
||||
},
|
||||
{
|
||||
name: 'Hasura',
|
||||
icon: <HasuraIcon className="h-4 w-4" />,
|
||||
route: 'hasura',
|
||||
slug: 'hasura',
|
||||
},
|
||||
{
|
||||
name: 'Auth',
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
route: 'users',
|
||||
slug: 'users',
|
||||
},
|
||||
{
|
||||
name: 'Storage',
|
||||
icon: <StorageIcon className="h-4 w-4" />,
|
||||
route: 'storage',
|
||||
slug: 'storage',
|
||||
},
|
||||
{
|
||||
name: 'Run',
|
||||
icon: <ServicesIcon className="h-4 w-4" />,
|
||||
route: 'services',
|
||||
slug: 'services',
|
||||
},
|
||||
{
|
||||
name: 'AI',
|
||||
icon: <AIIcon className="h-4 w-4" />,
|
||||
route: 'ai/auto-embeddings',
|
||||
slug: 'ai',
|
||||
},
|
||||
{
|
||||
name: 'Deployments',
|
||||
icon: <RocketIcon className="h-4 w-4" />,
|
||||
route: 'deployments',
|
||||
slug: 'deployments',
|
||||
},
|
||||
{
|
||||
name: 'Backups',
|
||||
icon: <CloudIcon className="h-4 w-4" />,
|
||||
route: 'backups',
|
||||
slug: 'backups',
|
||||
},
|
||||
{
|
||||
name: 'Logs',
|
||||
icon: <FileTextIcon className="h-4 w-4" />,
|
||||
route: 'logs',
|
||||
slug: 'logs',
|
||||
},
|
||||
{
|
||||
name: 'Metrics',
|
||||
icon: <GaugeIcon className="h-4 w-4" />,
|
||||
route: 'metrics',
|
||||
slug: 'metrics',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
route: 'settings/general',
|
||||
slug: 'settings',
|
||||
},
|
||||
];
|
||||
|
||||
const projectSettingsPages = [
|
||||
{ name: 'General', slug: 'general', route: 'general' },
|
||||
{
|
||||
name: 'Compute Resources',
|
||||
slug: 'resources',
|
||||
route: 'resources',
|
||||
},
|
||||
{ name: 'Database', slug: 'database', route: 'database' },
|
||||
{ name: 'Hasura', slug: 'hasura', route: 'hasura' },
|
||||
{
|
||||
name: 'Authentication',
|
||||
slug: 'authentication',
|
||||
route: 'authentication',
|
||||
},
|
||||
{
|
||||
name: 'Sign-In methods',
|
||||
slug: 'sign-in-methods',
|
||||
route: 'sign-in-methods',
|
||||
},
|
||||
{ name: 'Storage', slug: 'storage', route: 'storage' },
|
||||
{
|
||||
name: 'Roles and Permissions',
|
||||
slug: 'roles-and-permissions',
|
||||
route: 'roles-and-permissions',
|
||||
},
|
||||
{ name: 'SMTP', slug: 'smtp', route: 'smtp' },
|
||||
{ name: 'Git', slug: 'git', route: 'git' },
|
||||
{
|
||||
name: 'Environment Variables',
|
||||
slug: 'environment-variables',
|
||||
route: 'environment-variables',
|
||||
},
|
||||
{ name: 'Secrets', slug: 'secrets', route: 'secrets' },
|
||||
{
|
||||
name: 'Custom Domains',
|
||||
slug: 'custom-domains',
|
||||
route: 'custom-domains',
|
||||
},
|
||||
{
|
||||
name: 'Rate Limiting',
|
||||
slug: 'rate-limiting',
|
||||
route: 'rate-limiting',
|
||||
},
|
||||
{ name: 'AI', slug: 'ai', route: 'ai' },
|
||||
{ name: 'Configuration Editor', slug: 'editor', route: 'editor' },
|
||||
];
|
||||
|
||||
const createWorkspace = (workspace: Workspace) => {
|
||||
const result = {};
|
||||
|
||||
result[workspace.slug] = {
|
||||
index: workspace.slug,
|
||||
canMove: false,
|
||||
isFolder: true,
|
||||
children: [`${workspace.slug}-overview`, `${workspace.slug}-projects`],
|
||||
data: {
|
||||
name: workspace.name,
|
||||
slug: workspace.slug,
|
||||
type: 'workspace',
|
||||
targetUrl: `/${workspace.slug}`,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
|
||||
result[`${workspace.slug}-overview`] = {
|
||||
index: `${workspace.slug}-overview`,
|
||||
canMove: false,
|
||||
isFolder: false,
|
||||
children: null,
|
||||
data: {
|
||||
name: 'Overview',
|
||||
targetUrl: `/${workspace.slug}`,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
|
||||
result[`${workspace.slug}-projects`] = {
|
||||
index: `${workspace.slug}-projects`,
|
||||
canMove: false,
|
||||
isFolder: true,
|
||||
children: workspace.projects.map((app) => `${workspace.slug}-${app.slug}`),
|
||||
data: {
|
||||
name: 'Projects',
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
|
||||
workspace.projects.forEach((app) => {
|
||||
result[`${workspace.slug}-${app.slug}`] = {
|
||||
index: `${workspace.slug}-${app.slug}`,
|
||||
isFolder: true,
|
||||
canMove: false,
|
||||
canRename: false,
|
||||
data: {
|
||||
name: app.name,
|
||||
slug: app.slug,
|
||||
icon: <Box className="h-4 w-4" />,
|
||||
targetUrl: `/${workspace.slug}/${app.slug}`,
|
||||
},
|
||||
children: projectPages.map(
|
||||
(page) => `${workspace.slug}-${app.slug}-${page.slug}`,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
workspace.projects.forEach((_app) => {
|
||||
projectPages.forEach((_page) => {
|
||||
result[`${workspace.slug}-${_app.slug}-${_page.slug}`] = {
|
||||
index: `${workspace.slug}-${_app.slug}-${_page.slug}`,
|
||||
canMove: false,
|
||||
isFolder: _page.name === 'Settings',
|
||||
children:
|
||||
_page.name === 'Settings'
|
||||
? projectSettingsPages.map(
|
||||
(p) => `${workspace.slug}-${_app.slug}-settings-${p.slug}`,
|
||||
)
|
||||
: undefined,
|
||||
data: {
|
||||
name: _page.name,
|
||||
icon: _page.icon,
|
||||
isProjectPage: true,
|
||||
targetUrl: `/${workspace.slug}/${_app.slug}/${_page.route}`,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
});
|
||||
|
||||
// add the settings pages
|
||||
projectSettingsPages.forEach((p) => {
|
||||
result[`${workspace.slug}-${_app.slug}-settings-${p.slug}`] = {
|
||||
index: `${workspace.slug}-${_app.slug}-settings-${p.slug}`,
|
||||
canMove: false,
|
||||
isFolder: false,
|
||||
children: undefined,
|
||||
data: {
|
||||
name: p.name,
|
||||
targetUrl: `/${workspace.slug}/${_app.slug}/settings/${p.route}`,
|
||||
},
|
||||
canRename: false,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
type NavItem = {
|
||||
name: string;
|
||||
slug?: string;
|
||||
type?: string;
|
||||
icon?: ReactElement;
|
||||
targetUrl?: string;
|
||||
};
|
||||
|
||||
const buildNavTreeData = (
|
||||
workspaces: Workspace[],
|
||||
): { items: Record<TreeItemIndex, TreeItem<NavItem>> } => {
|
||||
const navTree = {
|
||||
items: {
|
||||
root: {
|
||||
index: 'root',
|
||||
canMove: false,
|
||||
isFolder: true,
|
||||
children: ['workspaces'],
|
||||
data: { name: 'root' },
|
||||
canRename: false,
|
||||
},
|
||||
workspaces: {
|
||||
index: 'workspaces',
|
||||
canMove: false,
|
||||
isFolder: true,
|
||||
children: workspaces.map((workspace) => workspace.slug),
|
||||
data: { name: 'Workspaces', type: 'workspaces-root' },
|
||||
canRename: false,
|
||||
},
|
||||
...workspaces.reduce(
|
||||
(acc, workspace) => ({ ...acc, ...createWorkspace(workspace) }),
|
||||
{},
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
return navTree;
|
||||
};
|
||||
|
||||
export default function WorkspacesNavTree() {
|
||||
const { workspaces } = useWorkspaces();
|
||||
const navTree = buildNavTreeData(workspaces);
|
||||
const [, setLastSlug] = useSSRLocalStorage('slug', null);
|
||||
|
||||
const { workspacesTreeViewState, setWorkspacesTreeViewState, setOpen } =
|
||||
useTreeNavState();
|
||||
|
||||
const renderItem = ({ arrow, context, item, children }) => {
|
||||
const navItemContent = () => (
|
||||
<>
|
||||
{item.data.icon && (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-start',
|
||||
context.isFocused ? 'text-primary' : '',
|
||||
)}
|
||||
>
|
||||
{item.data.icon}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
item?.index === 'workspaces' && 'font-bold',
|
||||
context.isFocused ? 'font-bold text-primary' : '',
|
||||
'max-w-40 truncate',
|
||||
)}
|
||||
>
|
||||
{item.data.name}
|
||||
</span>
|
||||
{item.data.type === 'workspaces-root' && (
|
||||
<HoverCard openDelay={0}>
|
||||
<HoverCardTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
'h-5 rounded-full bg-muted bg-orange-200 px-[6px] text-[10px] dark:bg-orange-500',
|
||||
)}
|
||||
>
|
||||
Legacy
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-64" side="top">
|
||||
<div className="whitespace-normal">
|
||||
<span>For more information read the </span>
|
||||
<Link
|
||||
href="https://nhost.io/blog/organization-billing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-medium"
|
||||
>
|
||||
announcement
|
||||
<ArrowSquareOutIcon className="mb-1 ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
{...context.itemContainerWithChildrenProps}
|
||||
className="flex flex-col gap-1"
|
||||
>
|
||||
<div className="flex flex-row items-center">
|
||||
{arrow}
|
||||
<Button
|
||||
asChild
|
||||
onClick={() => {
|
||||
if (item.data.type !== 'workspace') {
|
||||
context.focusItem();
|
||||
} else {
|
||||
// persist last slug if the nav item is a workspace
|
||||
setLastSlug(item.data.slug);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex h-8 w-full flex-row justify-start gap-1 bg-background px-1 text-foreground hover:bg-accent dark:hover:bg-muted',
|
||||
context.isFocused &&
|
||||
'bg-[#ebf3ff] hover:bg-[#ebf3ff] dark:bg-muted',
|
||||
item.data.disabled && 'pointer-events-none opacity-50',
|
||||
)}
|
||||
>
|
||||
{item.data.targetUrl ? (
|
||||
<NextLink
|
||||
href={item.data.targetUrl || '/'}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{navItemContent()}
|
||||
</NextLink>
|
||||
) : (
|
||||
<div className="cursor-pointer">{navItemContent()}</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ControlledTreeEnvironment
|
||||
items={navTree.items}
|
||||
getItemTitle={(item) => item.data.name}
|
||||
viewState={{
|
||||
'workspaces-nav-tree': workspacesTreeViewState,
|
||||
}}
|
||||
renderItemTitle={({ title }) => <span>{title}</span>}
|
||||
renderItemArrow={({ item, context }) => {
|
||||
if (!item.isFolder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => context.toggleExpandedState()}
|
||||
className="h-8 px-1"
|
||||
>
|
||||
{context.isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 font-bold" strokeWidth={3} />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" strokeWidth={3} />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}}
|
||||
renderItem={renderItem}
|
||||
renderTreeContainer={({ children, containerProps }) => (
|
||||
<div {...containerProps} className="w-full">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
renderItemsContainer={({ children, containerProps, depth }) => {
|
||||
if (depth === 0) {
|
||||
return (
|
||||
<ul {...containerProps} className="w-full">
|
||||
{children}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-row">
|
||||
<div className="flex justify-center px-[12px] pb-3">
|
||||
<div className="h-full w-0 border-r border-dashed" />
|
||||
</div>
|
||||
<ul {...containerProps} className="w-full">
|
||||
{children}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
canSearch={false}
|
||||
onExpandItem={(item) => {
|
||||
setWorkspacesTreeViewState(
|
||||
({ expandedItems: prevExpandedItems, ...rest }) => ({
|
||||
...rest,
|
||||
// Add item index to expandedItems only if it's not already present
|
||||
expandedItems: prevExpandedItems.includes(item.index)
|
||||
? prevExpandedItems
|
||||
: [...prevExpandedItems, item.index],
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onCollapseItem={(item) => {
|
||||
setWorkspacesTreeViewState(
|
||||
({ expandedItems: prevExpandedItems, ...rest }) => ({
|
||||
...rest,
|
||||
// Remove the item index from expandedItems
|
||||
expandedItems: prevExpandedItems.filter(
|
||||
(index) => index !== item.index,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
onFocusItem={(item) => {
|
||||
setWorkspacesTreeViewState((prevViewState) => ({
|
||||
...prevViewState,
|
||||
// Set the focused item
|
||||
focusedItem: item.index,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Tree
|
||||
rootItem="root"
|
||||
treeId="workspaces-nav-tree"
|
||||
treeLabel="Workspaces Navigation Tree"
|
||||
/>
|
||||
</ControlledTreeEnvironment>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +1,26 @@
|
||||
import { ContactUs } from '@/components/common/ContactUs';
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { ThemeSwitcher } from '@/components/common/ThemeSwitcher';
|
||||
import { Nav } from '@/components/presentational/Nav';
|
||||
import type { ButtonProps } from '@/components/ui/v2/Button';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { Drawer } from '@/components/ui/v2/Drawer';
|
||||
import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { MenuIcon } from '@/components/ui/v2/icons/MenuIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useNavigationVisible } from '@/features/projects/common/hooks/useNavigationVisible';
|
||||
import { useProjectRoutes } from '@/features/projects/common/hooks/useProjectRoutes';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useApolloClient } from '@apollo/client';
|
||||
import { useSignOut } from '@nhost/nextjs';
|
||||
import getConfig from 'next/config';
|
||||
import { useRouter } from 'next/router';
|
||||
import type { ReactNode } from 'react';
|
||||
import { cloneElement, Fragment, isValidElement, useState } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface MobileNavProps extends ButtonProps {}
|
||||
|
||||
interface MobileNavLinkProps extends ListItemButtonProps {
|
||||
/**
|
||||
* Link to navigate to.
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* Icon to display next to the text.
|
||||
*/
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
function MobileNavLink({
|
||||
className,
|
||||
exact = true,
|
||||
href,
|
||||
icon,
|
||||
...props
|
||||
}: MobileNavLinkProps) {
|
||||
const router = useRouter();
|
||||
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}`;
|
||||
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
|
||||
|
||||
const active = exact
|
||||
? router.asPath === finalUrl
|
||||
: router.asPath.startsWith(finalUrl);
|
||||
|
||||
return (
|
||||
<ListItem.Root
|
||||
className={twMerge('grid grid-flow-row gap-2 py-2', className)}
|
||||
>
|
||||
<ListItem.Button
|
||||
className="w-full"
|
||||
component={NavLink}
|
||||
href={finalUrl}
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Icon>
|
||||
{isValidElement(icon)
|
||||
? cloneElement(icon, { ...icon.props, className: 'w-4.5 h-4.5' })
|
||||
: null}
|
||||
</ListItem.Icon>
|
||||
|
||||
<ListItem.Text>{props.children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { allRoutes } = useProjectRoutes();
|
||||
const shouldDisplayNav = useNavigationVisible();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const { signOut } = useSignOut();
|
||||
const apolloClient = useApolloClient();
|
||||
@@ -113,76 +51,23 @@ export default function MobileNav({ className, ...props }: MobileNavProps) {
|
||||
className: 'w-full px-4 pt-18 pb-12 grid grid-flow-row gap-6',
|
||||
}}
|
||||
>
|
||||
{shouldDisplayNav && (
|
||||
<section>
|
||||
<Nav
|
||||
flow="row"
|
||||
className="w-full"
|
||||
aria-label="Mobile navigation"
|
||||
listProps={{ className: 'gap-2' }}
|
||||
>
|
||||
<List>
|
||||
{allRoutes.map(
|
||||
({ relativePath, label, icon, exact, disabled }, index) => (
|
||||
<Fragment key={relativePath}>
|
||||
<MobileNavLink
|
||||
href={relativePath}
|
||||
className="w-full"
|
||||
exact={exact}
|
||||
icon={icon}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</MobileNavLink>
|
||||
|
||||
{index < allRoutes.length - 1 && (
|
||||
<Divider component="li" />
|
||||
)}
|
||||
</Fragment>
|
||||
),
|
||||
)}
|
||||
</List>
|
||||
</Nav>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section
|
||||
className={twMerge(
|
||||
'grid grid-flow-row gap-3',
|
||||
!shouldDisplayNav && 'mt-2',
|
||||
)}
|
||||
>
|
||||
<section className="mt-2 grid grid-flow-row gap-3">
|
||||
<Text variant="h2" className="text-xl font-semibold">
|
||||
Resources
|
||||
</Text>
|
||||
|
||||
<List className="grid grid-flow-row gap-2">
|
||||
{isPlatform && (
|
||||
<Dropdown.Root>
|
||||
<Dropdown.Trigger
|
||||
className="justify-initial w-full"
|
||||
hideChevron
|
||||
asChild
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
component={NavLink}
|
||||
href="/support"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
component="span"
|
||||
className="w-full"
|
||||
role={undefined}
|
||||
>
|
||||
<ListItem.Text>Contact us</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
</Dropdown.Trigger>
|
||||
|
||||
<Dropdown.Content
|
||||
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<ContactUs className="max-w-md" />
|
||||
</Dropdown.Content>
|
||||
</Dropdown.Root>
|
||||
<ListItem.Text>Contact us</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
)}
|
||||
|
||||
<Divider component="li" />
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { AuthenticatedLayoutProps } from '@/components/layout/AuthenticatedLayout';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { DesktopNav } from '@/components/layout/DesktopNav';
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useNavigationVisible } from '@/features/projects/common/hooks/useNavigationVisible';
|
||||
import { useProjectRoutes } from '@/features/projects/common/hooks/useProjectRoutes';
|
||||
import { NextSeo } from 'next-seo';
|
||||
import { useRouter } from 'next/router';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ProjectLayoutProps extends AuthenticatedLayoutProps {
|
||||
/**
|
||||
* Props passed to the internal `<main />` element.
|
||||
*/
|
||||
mainContainerProps?: BoxProps;
|
||||
}
|
||||
|
||||
function ProjectLayoutContent({
|
||||
children,
|
||||
mainContainerProps: {
|
||||
className: mainContainerClassName,
|
||||
...mainContainerProps
|
||||
} = {},
|
||||
}: ProjectLayoutProps) {
|
||||
const { currentProject, loading, error } = useCurrentWorkspaceAndProject();
|
||||
|
||||
const router = useRouter();
|
||||
const shouldDisplayNav = useNavigationVisible();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { nhostRoutes } = useProjectRoutes();
|
||||
const pathWithoutWorkspaceAndProject = router.asPath.replace(
|
||||
/^\/[\w\-_[\]]+\/[\w\-_[\]]+/i,
|
||||
'',
|
||||
);
|
||||
const isRestrictedPath =
|
||||
!isPlatform &&
|
||||
nhostRoutes.some((route) =>
|
||||
pathWithoutWorkspaceAndProject.startsWith(
|
||||
route.relativeMainPath || route.relativePath,
|
||||
),
|
||||
);
|
||||
|
||||
// useNotFoundRedirect();
|
||||
|
||||
// useEffect(() => {
|
||||
// if (isPlatform || !router.isReady) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
// TODO // Double check what restricted path means here
|
||||
// if (isRestrictedPath) {
|
||||
// router.push('/local/local');
|
||||
// }
|
||||
// }, [isPlatform, isRestrictedPath, router]);
|
||||
|
||||
if (isRestrictedPath || loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
<>
|
||||
<DesktopNav className="top-0 hidden w-20 shrink-0 flex-col items-start sm:flex" />
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className={twMerge(
|
||||
'relative flex-auto overflow-y-auto',
|
||||
mainContainerClassName,
|
||||
)}
|
||||
{...mainContainerProps}
|
||||
>
|
||||
{children}
|
||||
|
||||
<NextSeo title="Local App" />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{shouldDisplayNav && (
|
||||
<DesktopNav className="top-0 hidden w-20 shrink-0 flex-col items-start sm:flex" />
|
||||
)}
|
||||
|
||||
<Box
|
||||
component="main"
|
||||
className={twMerge(
|
||||
'relative flex-auto overflow-y-auto',
|
||||
mainContainerClassName,
|
||||
)}
|
||||
{...mainContainerProps}
|
||||
>
|
||||
{children}
|
||||
|
||||
<NextSeo title={currentProject?.name} />
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrgProjectLayout({
|
||||
children,
|
||||
mainContainerProps,
|
||||
...props
|
||||
}: ProjectLayoutProps) {
|
||||
return (
|
||||
<AuthenticatedLayout {...props}>
|
||||
<ProjectLayoutContent mainContainerProps={mainContainerProps}>
|
||||
{children}
|
||||
</ProjectLayoutContent>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as OrgProjectLayout } from './OrgProjectLayout';
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { AuthenticatedLayoutProps } from '@/components/layout/AuthenticatedLayout';
|
||||
import { AuthenticatedLayout } from '@/components/layout/AuthenticatedLayout';
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useProjectRoutes } from '@/features/projects/common/hooks/useProjectRoutes';
|
||||
import { NextSeo } from 'next-seo';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface ProjectLayoutProps extends AuthenticatedLayoutProps {
|
||||
/**
|
||||
* Props passed to the internal `<main />` element.
|
||||
*/
|
||||
mainContainerProps?: BoxProps;
|
||||
}
|
||||
|
||||
function ProjectLayoutContent({
|
||||
children,
|
||||
mainContainerProps: {
|
||||
className: mainContainerClassName,
|
||||
...mainContainerProps
|
||||
} = {},
|
||||
}: ProjectLayoutProps) {
|
||||
const { project, loading, error } = useProject();
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { nhostRoutes } = useProjectRoutes();
|
||||
const pathWithoutWorkspaceAndProject = router.asPath.replace(
|
||||
/^\/[\w\-_[\]]+\/[\w\-_[\]]+/i,
|
||||
'',
|
||||
);
|
||||
const isRestrictedPath =
|
||||
!isPlatform &&
|
||||
nhostRoutes.some((route) =>
|
||||
pathWithoutWorkspaceAndProject.startsWith(
|
||||
route.relativeMainPath || route.relativePath,
|
||||
),
|
||||
);
|
||||
|
||||
// TODO(orgs) 1
|
||||
// useNotFoundRedirect();
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlatform || !router.isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRestrictedPath) {
|
||||
router.push('/local/local');
|
||||
}
|
||||
}, [isPlatform, isRestrictedPath, router]);
|
||||
|
||||
if (isRestrictedPath || loading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
<Box
|
||||
component="main"
|
||||
className={twMerge(
|
||||
'relative flex-auto overflow-y-auto',
|
||||
mainContainerClassName,
|
||||
)}
|
||||
{...mainContainerProps}
|
||||
>
|
||||
{children}
|
||||
<NextSeo title="Local App" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="main"
|
||||
className={twMerge(
|
||||
'relative flex-auto overflow-y-auto',
|
||||
mainContainerClassName,
|
||||
)}
|
||||
{...mainContainerProps}
|
||||
>
|
||||
{children}
|
||||
|
||||
<NextSeo title={project?.name} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This components wraps the content in an `AuthenticatedLayout` and fetches
|
||||
* project and workspace data from the API. Use this layout for pages where
|
||||
* project related data is necessary (e.g: Overview, Data Browser, etc.).
|
||||
*/
|
||||
export default function ProjectLayout({
|
||||
children,
|
||||
mainContainerProps,
|
||||
...props
|
||||
}: ProjectLayoutProps) {
|
||||
return (
|
||||
<AuthenticatedLayout {...props}>
|
||||
<ProjectLayoutContent mainContainerProps={mainContainerProps}>
|
||||
{children}
|
||||
</ProjectLayoutContent>
|
||||
</AuthenticatedLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './ProjectLayout';
|
||||
export { default as ProjectLayout } from './ProjectLayout';
|
||||
@@ -1,69 +0,0 @@
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { ProjectLayoutProps } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useTheme } from '@mui/material';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsLayoutProps extends ProjectLayoutProps {}
|
||||
|
||||
export default function SettingsLayout({ children }: SettingsLayoutProps) {
|
||||
const theme = useTheme();
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const hasGitRepo = !!currentProject?.githubRepository;
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex w-full flex-auto flex-col overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<Box
|
||||
sx={{ backgroundColor: 'background.default' }}
|
||||
className="flex h-full flex-col"
|
||||
>
|
||||
<RetryableErrorBoundary>
|
||||
<div className="flex flex-col space-y-2">
|
||||
{hasGitRepo && (
|
||||
<Alert
|
||||
severity="warning"
|
||||
className="grid grid-flow-row place-content-center gap-2"
|
||||
>
|
||||
<Text color="warning" className="text-sm">
|
||||
As you have a connected repository, make sure to synchronize
|
||||
your changes with{' '}
|
||||
<code
|
||||
className={twMerge(
|
||||
'rounded-md px-2 py-px',
|
||||
theme.palette.mode === 'dark'
|
||||
? 'bg-brown text-copper'
|
||||
: 'bg-slate-200 text-slate-700',
|
||||
)}
|
||||
>
|
||||
nhost config pull
|
||||
</code>{' '}
|
||||
or they may be reverted with the next push.
|
||||
<br />
|
||||
If there are multiple projects linked to the same repository
|
||||
and you only want these changes to apply to a subset of them,
|
||||
please check out{' '}
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
href="https://docs.nhost.io/guides/cli/configuration-overlays#configuration-overlays"
|
||||
>
|
||||
Configuration Overlays
|
||||
</a>{' '}
|
||||
for guidance.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</RetryableErrorBoundary>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './SettingsLayout';
|
||||
export { default as SettingsLayout } from './SettingsLayout';
|
||||
@@ -1,266 +0,0 @@
|
||||
import { NavLink } from '@/components/common/NavLink';
|
||||
import { Backdrop } from '@/components/ui/v2/Backdrop';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { IconButton } from '@/components/ui/v2/IconButton';
|
||||
import { SlidersIcon } from '@/components/ui/v2/icons/SlidersIcon';
|
||||
import { List } from '@/components/ui/v2/List';
|
||||
import type { ListItemButtonProps } from '@/components/ui/v2/ListItem';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface SettingsSidebarProps extends Omit<BoxProps, 'children'> {}
|
||||
|
||||
interface SettingsNavLinkProps extends ListItemButtonProps {
|
||||
/**
|
||||
* Link to navigate to.
|
||||
*/
|
||||
href: string;
|
||||
/**
|
||||
* Determines whether or not the link should be active if it's href exactly
|
||||
* matches the current route.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
exact?: boolean;
|
||||
/**
|
||||
* Class name passed to the text element.
|
||||
*/
|
||||
textClassName?: string;
|
||||
}
|
||||
|
||||
function SettingsNavLink({
|
||||
exact = true,
|
||||
href,
|
||||
children,
|
||||
textClassName,
|
||||
...props
|
||||
}: SettingsNavLinkProps) {
|
||||
const router = useRouter();
|
||||
const baseUrl = `/${router.query.workspaceSlug}/${router.query.appSlug}/settings`;
|
||||
const finalUrl = href && href !== '/' ? `${baseUrl}${href}` : baseUrl;
|
||||
|
||||
const active = exact
|
||||
? router.asPath === finalUrl
|
||||
: router.asPath.startsWith(finalUrl);
|
||||
|
||||
return (
|
||||
<ListItem.Root>
|
||||
<ListItem.Button
|
||||
dense
|
||||
href={finalUrl}
|
||||
component={NavLink}
|
||||
selected={active}
|
||||
{...props}
|
||||
>
|
||||
<ListItem.Text className={textClassName}>{children}</ListItem.Text>
|
||||
</ListItem.Button>
|
||||
</ListItem.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsSidebar({
|
||||
className,
|
||||
...props
|
||||
}: SettingsSidebarProps) {
|
||||
const isPlatform = useIsPlatform();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
|
||||
function toggleExpanded() {
|
||||
setExpanded(!expanded);
|
||||
}
|
||||
|
||||
function handleSelect() {
|
||||
setExpanded(false);
|
||||
}
|
||||
|
||||
function closeSidebarWhenEscapeIsPressed(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
setExpanded(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}
|
||||
|
||||
return () =>
|
||||
document.removeEventListener('keydown', closeSidebarWhenEscapeIsPressed);
|
||||
}, []);
|
||||
|
||||
if (!currentProject) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Backdrop
|
||||
open={expanded}
|
||||
className="absolute bottom-0 left-0 right-0 top-0 z-[34] md:hidden"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Close sidebar overlay"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
setExpanded(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box
|
||||
component="aside"
|
||||
className={twMerge(
|
||||
'absolute top-0 z-[35] flex h-full w-full flex-col justify-between overflow-auto border-r-1 pb-17 pt-2 motion-safe:transition-transform md:relative md:z-0 md:h-full md:pb-0 md:pt-2.5 md:transition-none',
|
||||
expanded ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<nav aria-label="Settings navigation" className="px-2">
|
||||
<List className="grid gap-2">
|
||||
<SettingsNavLink
|
||||
href="/general"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
General
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/resources"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Compute Resources
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/database"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Database
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/hasura"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Hasura
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/authentication"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Authentication
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/sign-in-methods"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Sign-In Methods
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/storage"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Storage
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/roles-and-permissions"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Roles and Permissions
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink href="/smtp" exact={false} onClick={handleSelect}>
|
||||
SMTP
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/git"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
disabled={!isPlatform}
|
||||
>
|
||||
Git
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/environment-variables"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Environment Variables
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/secrets"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Secrets
|
||||
</SettingsNavLink>
|
||||
|
||||
<SettingsNavLink
|
||||
href="/custom-domains"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Custom Domains
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink
|
||||
href="/rate-limiting"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
>
|
||||
Rate Limiting
|
||||
</SettingsNavLink>
|
||||
<SettingsNavLink href="/ai" exact={false} onClick={handleSelect}>
|
||||
AI
|
||||
</SettingsNavLink>
|
||||
</List>
|
||||
</nav>
|
||||
<Box className="border-t">
|
||||
<SettingsNavLink
|
||||
href="/editor"
|
||||
exact={false}
|
||||
onClick={handleSelect}
|
||||
className="flex w-full border group-focus-within:pr-9 group-hover:pr-9 group-active:pr-9"
|
||||
textClassName="flex w-full justify-center"
|
||||
>
|
||||
<div className="flex w-full flex-row items-center justify-center space-x-4 py-2.5">
|
||||
<SlidersIcon />
|
||||
<span className="flex">Configuration Editor</span>
|
||||
</div>
|
||||
</SettingsNavLink>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<IconButton
|
||||
className="absolute bottom-4 left-4 z-[38] h-11 w-11 rounded-full md:hidden"
|
||||
onClick={toggleExpanded}
|
||||
aria-label="Toggle sidebar"
|
||||
>
|
||||
<Image
|
||||
width={16}
|
||||
height={16}
|
||||
src="/assets/table.svg"
|
||||
alt="A monochrome table"
|
||||
/>
|
||||
</IconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './SettingsSidebar';
|
||||
export { default as SettingsSidebar } from './SettingsSidebar';
|
||||
@@ -5,7 +5,7 @@ import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { ThemeProvider } from '@/components/ui/v2/ThemeProvider';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import GlobalStyles from '@mui/material/GlobalStyles';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import { Chip } from '@/components/ui/v2/Chip';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
|
||||
export interface StateBadgeProps {
|
||||
/**
|
||||
* This is the current state of the application.
|
||||
*/
|
||||
state: ApplicationStatus;
|
||||
/**
|
||||
* This is the desired state of the application.
|
||||
*/
|
||||
desiredState: ApplicationStatus;
|
||||
/**
|
||||
* The title to show on the application state badge.
|
||||
*/
|
||||
title: string;
|
||||
}
|
||||
|
||||
function getNormalizedTitle(title: string) {
|
||||
if (title === 'Errored') {
|
||||
return 'Live';
|
||||
}
|
||||
|
||||
if (title === 'Empty') {
|
||||
return 'Setting up';
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
export default function StateBadge({
|
||||
title,
|
||||
state,
|
||||
desiredState,
|
||||
}: StateBadgeProps) {
|
||||
if (
|
||||
desiredState === ApplicationStatus.Paused &&
|
||||
state === ApplicationStatus.Live
|
||||
) {
|
||||
return <Chip size="small" color="default" label="Pausing" />;
|
||||
}
|
||||
|
||||
const normalizedTitle = getNormalizedTitle(title);
|
||||
|
||||
if (
|
||||
state === ApplicationStatus.Empty ||
|
||||
state === ApplicationStatus.Unpausing ||
|
||||
state === ApplicationStatus.Updating
|
||||
) {
|
||||
return <Chip size="small" label={normalizedTitle} color="warning" />;
|
||||
}
|
||||
|
||||
if (state === ApplicationStatus.Errored || state === ApplicationStatus.Live) {
|
||||
return <Chip size="small" label={normalizedTitle} color="success" />;
|
||||
}
|
||||
|
||||
return <Chip size="small" color="default" label={normalizedTitle} />;
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './StateBadge';
|
||||
export { default as StateBadge } from './StateBadge';
|
||||
@@ -2,84 +2,41 @@ import { render, screen } from '@/tests/testUtils';
|
||||
import { test } from 'vitest';
|
||||
import ErrorToast from './ErrorToast';
|
||||
|
||||
const oneMemberByWorkspaceError = {
|
||||
const runUpdateError = {
|
||||
name: 'ApolloError',
|
||||
graphQLErrors: [
|
||||
{
|
||||
message: 'database query error',
|
||||
extensions: {
|
||||
path: '$.selectionSet.insertApp.args.object',
|
||||
code: 'unexpected',
|
||||
internal: {
|
||||
arguments: [],
|
||||
error: {
|
||||
description: null,
|
||||
exec_status: 'FatalError',
|
||||
hint: null,
|
||||
message:
|
||||
'Only one workspace member is allowed for individual plans',
|
||||
status_code: 'P0001',
|
||||
},
|
||||
prepared: false,
|
||||
statement: '.....',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
protocolErrors: [],
|
||||
clientErrors: [],
|
||||
networkError: null,
|
||||
message: 'database query error',
|
||||
};
|
||||
|
||||
const changeNodeInvalidVersionError = {
|
||||
name: 'ApolloError',
|
||||
graphQLErrors: [
|
||||
{
|
||||
message:
|
||||
'failed to resolve config: failed to validate config: config is not valid: #Config.functions.node.version: 2 errors in empty disjunction: (and 2 more errors)',
|
||||
path: ['replaceConfigRawJSON'],
|
||||
message: 'The port value "302300" is out of range',
|
||||
path: ['replaceRunServiceConfig', 'config', 'ports', 0, 'port'],
|
||||
},
|
||||
],
|
||||
protocolErrors: [],
|
||||
clientErrors: [],
|
||||
networkError: null,
|
||||
message:
|
||||
'failed to resolve config: failed to validate config: config is not valid: #Config.functions.node.version: 2 errors in empty disjunction: (and 2 more errors)',
|
||||
'problem trying to parse string: strconv.ParseInt: parsing "302300": value out of range',
|
||||
cause: {
|
||||
message:
|
||||
'problem trying to parse string: strconv.ParseInt: parsing "302300": value out of range',
|
||||
path: ['replaceRunServiceConfig', 'config', 'ports', 0, 'port'],
|
||||
},
|
||||
};
|
||||
|
||||
test('should render the error message when creating a project with an individual plan in a workspace with multiple users', () => {
|
||||
const errorMessage =
|
||||
'An error occurred while creating the project. Please try again.';
|
||||
test('should render the available Apollo error message but not the fallback message', () => {
|
||||
const fallbackErrorMessage =
|
||||
'An error occurred while updating the service. Please try again.';
|
||||
render(
|
||||
<ErrorToast
|
||||
isVisible
|
||||
errorMessage={errorMessage}
|
||||
error={oneMemberByWorkspaceError}
|
||||
errorMessage={fallbackErrorMessage}
|
||||
error={runUpdateError}
|
||||
close={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(fallbackErrorMessage)).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Only one workspace member is allowed for individual plans/i,
|
||||
),
|
||||
screen.getByText(/The port value "302300" is out of range/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the error message when changing the node version to an invalid value in configuration editor', () => {
|
||||
const errorMessage =
|
||||
'An error occurred while saving configuration. Please try again.';
|
||||
render(
|
||||
<ErrorToast
|
||||
isVisible
|
||||
errorMessage={errorMessage}
|
||||
error={changeNodeInvalidVersionError}
|
||||
close={() => {}}
|
||||
/>,
|
||||
);
|
||||
const regex =
|
||||
/failed to resolve config: failed to validate config: config is not valid: #Config\.functions\.node\.version: 2 errors in empty disjunction: \(and 2 more errors\)/i;
|
||||
|
||||
expect(screen.getByText(regex)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { ChevronUpIcon } from '@/components/ui/v2/icons/ChevronUpIcon';
|
||||
import { CopyIcon } from '@/components/ui/v2/icons/CopyIcon';
|
||||
import { XIcon } from '@/components/ui/v2/icons/XIcon';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { getToastBackgroundColor } from '@/utils/constants/settings';
|
||||
import { copy } from '@/utils/copy';
|
||||
import type { ApolloError } from '@apollo/client';
|
||||
@@ -73,11 +73,11 @@ export default function ErrorToast({
|
||||
const { asPath } = useRouter();
|
||||
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
const { currentProject } = useCurrentWorkspaceAndProject();
|
||||
const { project } = useProject();
|
||||
|
||||
const errorDetails: ErrorDetails = {
|
||||
info: {
|
||||
projectId: currentProject?.id,
|
||||
projectId: project?.id,
|
||||
userId: userData?.id || 'local',
|
||||
url: asPath,
|
||||
},
|
||||
|
||||
@@ -1,76 +1,178 @@
|
||||
'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;
|
||||
defaultMonth: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
defaultMonth,
|
||||
...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" />;
|
||||
},
|
||||
}}
|
||||
defaultMonth={defaultMonth}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -35,16 +35,23 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface CommandInputProps {
|
||||
prefix?: React.ReactNode;
|
||||
prefixClassName?: string;
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
|
||||
prefix?: React.ReactNode;
|
||||
}
|
||||
>(({ className, prefix, ...props }, ref) => (
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> &
|
||||
CommandInputProps
|
||||
>(({ className, prefix, prefixClassName, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
{prefix && (
|
||||
<span className="pointer-events-none flex items-center text-muted-foreground">
|
||||
<span
|
||||
title={prefix}
|
||||
className={cn('text-muted-foreground', prefixClassName)}
|
||||
>
|
||||
{prefix}
|
||||
</span>
|
||||
)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user