Compare commits
20 Commits
@nhost/rea
...
@nhost/nex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b6a4adf40 | ||
|
|
6cc8f954e1 | ||
|
|
1821df7a96 | ||
|
|
ab8a55ede4 | ||
|
|
39eb70678b | ||
|
|
e3cd5f858f | ||
|
|
69d9ab60c8 | ||
|
|
a8961c0ab0 | ||
|
|
6b8163d21f | ||
|
|
a21553c774 | ||
|
|
2dd4df5170 | ||
|
|
403a45d2cf | ||
|
|
05f063b8e2 | ||
|
|
09f5bed1e8 | ||
|
|
91d5fbba42 | ||
|
|
498363db25 | ||
|
|
8c2779930b | ||
|
|
0aa27a2fd1 | ||
|
|
8656749e5a | ||
|
|
d097eb8feb |
@@ -30,6 +30,14 @@ runs:
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
- shell: bash
|
||||
name: Use Latest Corepack
|
||||
run: |
|
||||
echo "Before: corepack version => $(corepack --version || echo 'not installed')"
|
||||
npm install -g corepack@latest
|
||||
echo "After : corepack version => $(corepack --version)"
|
||||
corepack enable
|
||||
pnpm --version
|
||||
- shell: bash
|
||||
name: Install packages
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
4
.github/workflows/test-nhost-cli-action.yaml
vendored
4
.github/workflows/test-nhost-cli-action.yaml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
path: packages/nhost-js
|
||||
start: true
|
||||
- name: should be running
|
||||
run: curl -sSf 'https://local.hasura.nhost.run' > /dev/null
|
||||
run: curl -sSf 'https://local.hasura.local.nhost.run' > /dev/null
|
||||
|
||||
stop:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
start: true
|
||||
- name: should find the injected hasura-auth version
|
||||
run: |
|
||||
VERSION=$(curl -sSf 'https://local.auth.nhost.run/v1/version')
|
||||
VERSION=$(curl -sSf 'https://local.auth.local.nhost.run/v1/version')
|
||||
EXPECTED_VERSION='{"version":"v0.20.1"}'
|
||||
if [ "$VERSION" != "$EXPECTED_VERSION" ]; then
|
||||
echo "Expected version $EXPECTED_VERSION but got $VERSION"
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,3 +63,5 @@ out/
|
||||
# Nix
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
/.vscode/
|
||||
|
||||
12
README.md
12
README.md
@@ -4,7 +4,7 @@
|
||||
|
||||
# Nhost
|
||||
|
||||
<a href="https://docs.nhost.io/#quickstart">Quickstart</a>
|
||||
<a href="https://docs.nhost.io/introduction#quick-start-guides">Quickstart</a>
|
||||
<span> • </span>
|
||||
<a href="http://nhost.io/">Website</a>
|
||||
<span> • </span>
|
||||
@@ -36,7 +36,7 @@ Nhost consists of open source software:
|
||||
- Authentication: [Hasura Auth](https://github.com/nhost/hasura-auth/)
|
||||
- Storage: [Hasura Storage](https://github.com/nhost/hasura-storage)
|
||||
- Serverless Functions: Node.js (JavaScript and TypeScript)
|
||||
- [Nhost CLI](https://docs.nhost.io/cli) for local development
|
||||
- [Nhost CLI](https://docs.nhost.io/development/cli/overview) for local development
|
||||
|
||||
## Architecture of Nhost
|
||||
|
||||
@@ -89,12 +89,12 @@ await nhost.graphql.request(`{
|
||||
Nhost is frontend agnostic, which means Nhost works with all frontend frameworks.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://docs.nhost.io/platform/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/nextjs"><img src="assets/nextjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/nuxtjs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/platform/quickstarts/react"><img src="assets/react.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/react"><img src="assets/react.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/react-native.svg"/></a>
|
||||
<a href="https://docs.nhost.io/reference/javascript"><img src="assets/svelte.svg"/></a>
|
||||
<a href="https://docs.nhost.io/platform/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
||||
<a href="https://docs.nhost.io/guides/quickstarts/vue"><img src="assets/vuejs.svg"/></a>
|
||||
</div>
|
||||
|
||||
# Resources
|
||||
@@ -140,7 +140,7 @@ This repository, and most of our other open source projects, are licensed under
|
||||
|
||||
Here are some ways of contributing to making Nhost better:
|
||||
|
||||
- **[Try out Nhost](https://docs.nhost.io/get-started/quick-start)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- **[Try out Nhost](https://docs.nhost.io/introduction)**, and think of ways to make the service better. Let us know here on GitHub.
|
||||
- Join our [Discord](https://discord.com/invite/9V7Qb2U) and connect with other members to share and learn from.
|
||||
- Send a pull request to any of our [open source repositories](https://github.com/nhost) on Github. Check our [contribution guide](https://github.com/nhost/nhost/blob/main/CONTRIBUTING.md) and our [developers guide](https://github.com/nhost/nhost/blob/main/DEVELOPERS.md) for more details about how to contribute. We're looking forward to your contribution!
|
||||
|
||||
|
||||
@@ -3,18 +3,19 @@ NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
|
||||
# Environment Variables for Self Hosting and Local Development
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.local.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||
|
||||
# Environment Variables when running the Nhost Dashboard against the Nhost Backend
|
||||
NEXT_PUBLIC_STRIPE_PK=<nhost_stripe_public_key>
|
||||
NEXT_PUBLIC_GITHUB_APP_INSTALL_URL=<github_app_install_url>
|
||||
NEXT_PUBLIC_ANALYTICS_WRITE_KEY=<analytics_write_key>
|
||||
NEXT_PUBLIC_SEGMENT_CDN_URL=<segment_cdn_url>
|
||||
NEXT_PUBLIC_NHOST_BRAGI_WEBSOCKET=<nhost_bragi_websocket>
|
||||
|
||||
NEXT_PUBLIC_ZENDESK_URL=
|
||||
@@ -22,6 +23,6 @@ NEXT_PUBLIC_ZENDESK_API_KEY=
|
||||
NEXT_PUBLIC_ZENDESK_USER_EMAIL=
|
||||
|
||||
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
CODEGEN_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
CODEGEN_HASURA_ADMIN_SECRET=nhost-admin-secret
|
||||
NEXT_PUBLIC_TURNSTILE_SITE_KEY=FIXME
|
||||
2
dashboard/.vscode/settings.json
vendored
2
dashboard/.vscode/settings.json
vendored
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ ENV NEXT_PUBLIC_NHOST_CONFIGSERVER_URL=__NEXT_PUBLIC_NHOST_CONFIGSERVER_URL__
|
||||
RUN yarn global add pnpm@9.15.0
|
||||
COPY .gitignore .gitignore
|
||||
COPY --from=pruner /app/out/json/ .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml .
|
||||
COPY --from=pruner /app/out/pnpm-*.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY --from=pruner /app/out/full/ .
|
||||
|
||||
@@ -51,13 +51,13 @@ You can connect the Nhost Dashboard to your locally running backend by setting t
|
||||
```bash
|
||||
NEXT_PUBLIC_ENV=dev
|
||||
NEXT_PUBLIC_NHOST_PLATFORM=false
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.nhost.run
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL=https://local.auth.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL=https://local.functions.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL=https://local.graphql.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL=https://local.storage.local.nhost.run/v1
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL=https://local.hasura.local.nhost.run
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL=https://local.hasura.local.nhost.run/v1/migrations
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL=https://local.hasura.local.nhost.run
|
||||
```
|
||||
|
||||
This will connect the Nhost Dashboard to your locally running Nhost backend.
|
||||
|
||||
149
dashboard/e2e/database/permissions-table.test.ts
Normal file
149
dashboard/e2e/database/permissions-table.test.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import {
|
||||
clickPermissionButton,
|
||||
navigateToProject,
|
||||
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,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Press three horizontal dots more options button next to the table name
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: /edit permissions/i }).click();
|
||||
|
||||
await clickPermissionButton({ page, role: 'user', permission: 'Select' });
|
||||
|
||||
await page.getByLabel('Without any checks').click();
|
||||
await page.getByRole('button', { name: /select all/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/permission has been saved successfully/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('should create a table with role permissions and a custom check to select rows', async () => {
|
||||
await page.getByRole('button', { name: /new table/i }).click();
|
||||
await expect(page.getByText(/create a new table/i)).toBeVisible();
|
||||
|
||||
const tableName = snakeCase(faker.lorem.words(3));
|
||||
|
||||
await prepareTable({
|
||||
page,
|
||||
name: tableName,
|
||||
primaryKey: 'id',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', defaultValue: 'gen_random_uuid()' },
|
||||
{ name: 'title', type: 'text' },
|
||||
],
|
||||
});
|
||||
|
||||
// create table
|
||||
await page.getByRole('button', { name: /create/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/public/${tableName}`,
|
||||
);
|
||||
|
||||
await expect(
|
||||
page.getByRole('link', { name: tableName, exact: true }),
|
||||
).toBeVisible();
|
||||
|
||||
// Press three horizontal dots more options button next to the table name
|
||||
await page
|
||||
.locator(`li:has-text("${tableName}") #table-management-menu button`)
|
||||
.click();
|
||||
|
||||
await page.getByRole('menuitem', { name: /edit permissions/i }).click();
|
||||
|
||||
await clickPermissionButton({ page, role: 'user', permission: 'Select' });
|
||||
|
||||
await page.getByLabel('With custom check').click();
|
||||
|
||||
// await page.getByRole('combobox', { name: /select a column/i }).click();
|
||||
await page.getByText('Select a column', { exact: true }).click();
|
||||
|
||||
const columnSelector = page.locator('input[role="combobox"]');
|
||||
|
||||
await columnSelector.fill('id');
|
||||
|
||||
await columnSelector.press('Enter');
|
||||
|
||||
await expect(page.getByText(/_eq/i)).toBeVisible();
|
||||
|
||||
// limit on number of rows fetched per request.
|
||||
await page.locator('#limit').fill('100');
|
||||
|
||||
await page.getByText('Select variable...', { exact: true }).click();
|
||||
|
||||
const variableSelector = await page.locator('input[role="combobox"]');
|
||||
|
||||
await variableSelector.fill('X-Hasura-User-Id');
|
||||
|
||||
await variableSelector.press('Enter');
|
||||
|
||||
await page.getByRole('button', { name: /select all/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: /save/i }).click();
|
||||
|
||||
await expect(
|
||||
page.getByText(/permission has been saved successfully/i),
|
||||
).toBeVisible();
|
||||
});
|
||||
61
dashboard/e2e/teardown/database.teardown.ts
Normal file
61
dashboard/e2e/teardown/database.teardown.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
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,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await page.getByRole('link', { name: /sql editor/i }).click();
|
||||
|
||||
await page.waitForURL(
|
||||
`/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/database/browser/default/editor`,
|
||||
);
|
||||
|
||||
const inputField = page.locator('[contenteditable]');
|
||||
await inputField.fill(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
await page.locator('button[type="button"]', { hasText: /run/i }).click();
|
||||
await expect(page.getByText(/success/i)).toBeVisible();
|
||||
});
|
||||
@@ -191,3 +191,23 @@ export function generateTestEmail(prefix: string = 'Nhost_Test_') {
|
||||
|
||||
return [prefix, email].join('');
|
||||
}
|
||||
|
||||
export async function clickPermissionButton({
|
||||
page,
|
||||
role,
|
||||
permission,
|
||||
}: {
|
||||
page: Page;
|
||||
role: string;
|
||||
permission: 'Insert' | 'Select' | 'Update' | 'Delete';
|
||||
}) {
|
||||
const permissionIndex =
|
||||
['Insert', 'Select', 'Update', 'Delete'].indexOf(permission) + 1;
|
||||
|
||||
await page
|
||||
.locator('tr', { hasText: role })
|
||||
.locator('td')
|
||||
.nth(permissionIndex)
|
||||
.locator('button')
|
||||
.click();
|
||||
}
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import {
|
||||
TEST_DASHBOARD_URL,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_ADMIN_SECRET,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
} from '@/e2e/env';
|
||||
import { navigateToProject } from '@/e2e/utils';
|
||||
import { chromium } from '@playwright/test';
|
||||
|
||||
async function globalTeardown() {
|
||||
const browser = await chromium.launch({ slowMo: 1000 });
|
||||
|
||||
const context = await browser.newContext({
|
||||
baseURL: TEST_DASHBOARD_URL,
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
await navigateToProject({
|
||||
page,
|
||||
orgSlug: TEST_ORGANIZATION_SLUG,
|
||||
projectSubdomain: TEST_PROJECT_SUBDOMAIN,
|
||||
});
|
||||
|
||||
const pagePromise = context.waitForEvent('page');
|
||||
|
||||
await page.getByRole('link', { name: /hasura/i }).click();
|
||||
await page.getByRole('link', { name: /open hasura/i }).click();
|
||||
|
||||
const hasuraPage = await pagePromise;
|
||||
await hasuraPage.waitForLoadState();
|
||||
|
||||
const adminSecretInput = hasuraPage.getByPlaceholder(/enter admin-secret/i);
|
||||
|
||||
// note: a more ideal way would be to paste from clipboard, but Playwright
|
||||
// doesn't support that yet
|
||||
await adminSecretInput.fill(TEST_PROJECT_ADMIN_SECRET);
|
||||
await adminSecretInput.press('Enter');
|
||||
|
||||
// note: getByRole doesn't work here
|
||||
await hasuraPage.locator('a', { hasText: /data/i }).nth(0).click();
|
||||
await hasuraPage.locator('[data-test="sql-link"]').click();
|
||||
|
||||
// Set the value of the Ace code editor using JavaScript evaluation in the browser context
|
||||
await hasuraPage.evaluate(() => {
|
||||
const editor = ace.edit('raw_sql');
|
||||
|
||||
editor.setValue(`
|
||||
DO $$ DECLARE
|
||||
tablename text;
|
||||
BEGIN
|
||||
FOR tablename IN
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
LOOP
|
||||
EXECUTE 'DROP TABLE IF EXISTS public.' || quote_ident(tablename) || ' CASCADE';
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
});
|
||||
|
||||
await hasuraPage.getByRole('button', { name: /run!/i }).click();
|
||||
await hasuraPage.getByText(/sql executed!/i).waitFor();
|
||||
}
|
||||
|
||||
export default globalTeardown;
|
||||
@@ -1,5 +1,5 @@
|
||||
schema:
|
||||
- https://local.graphql.nhost.run/v1:
|
||||
- https://local.graphql.local.nhost.run/v1:
|
||||
headers:
|
||||
x-hasura-admin-secret: nhost-admin-secret
|
||||
generates:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.18.0",
|
||||
"version": "2.21.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -56,7 +56,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.2",
|
||||
"@segment/snippet": "^4.16.2",
|
||||
"@segment/analytics-next": "^1.77.0",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^1.54.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
@@ -67,7 +67,6 @@
|
||||
"@uiw/codemirror-theme-bbedit": "^4.22.2",
|
||||
"@uiw/codemirror-theme-github": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"analytics-node": "^6.2.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^1.2.1",
|
||||
|
||||
@@ -15,7 +15,6 @@ export default defineConfig({
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
globalTeardown: require.resolve('./global-teardown'),
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
@@ -28,6 +27,11 @@ export default defineConfig({
|
||||
{
|
||||
name: 'setup',
|
||||
testMatch: ['**/setup/*.setup.ts'],
|
||||
teardown: 'teardown',
|
||||
},
|
||||
{
|
||||
name: 'teardown',
|
||||
testMatch: ['**/teardown/*.teardown.ts'],
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
|
||||
30
dashboard/src/components/analytics/analytics.tsx
Normal file
30
dashboard/src/components/analytics/analytics.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function Analytics() {
|
||||
const router = useRouter();
|
||||
const { org } = useCurrentOrg();
|
||||
const { project } = useProject();
|
||||
|
||||
useEffect(() => {
|
||||
const customProperties = {
|
||||
organizationSlug: org?.slug || '',
|
||||
projectSubdomain: project?.subdomain || '',
|
||||
};
|
||||
|
||||
analytics.page(customProperties);
|
||||
|
||||
const handleRouteChange = () => analytics.page(customProperties);
|
||||
|
||||
router.events.on('routeChangeComplete', handleRouteChange);
|
||||
|
||||
return () => {
|
||||
router.events.off('routeChangeComplete', handleRouteChange);
|
||||
};
|
||||
}, [router.events, org?.slug, project?.subdomain]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||
|
||||
interface Props {
|
||||
buttonText?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function OpenTransferDialogButton({ buttonText, onClick }: Props) {
|
||||
const text = buttonText ?? 'Transfer Project';
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const handleClick = () => {
|
||||
if (isOwner) {
|
||||
onClick();
|
||||
} else {
|
||||
openAlertDialog({
|
||||
title: "You can't migrate this project",
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Ask an owner of this organization to migrate the project.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
secondaryButtonText: 'I understand',
|
||||
hidePrimaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Button className="max-w-xs lg:w-auto" onClick={handleClick}>
|
||||
{text}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default OpenTransferDialogButton;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as OpenTransferDialogButton } from './OpenTransferDialogButton';
|
||||
@@ -1,14 +1,13 @@
|
||||
import { useDialog } from '@/components/common/DialogProvider';
|
||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { useIsCurrentUserOwner } from '@/features/orgs/projects/common/hooks/useIsCurrentUserOwner';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
|
||||
import Image from 'next/image';
|
||||
import { type ReactNode } from 'react';
|
||||
|
||||
@@ -21,11 +20,11 @@ export default function UpgradeToProBanner({
|
||||
title,
|
||||
description,
|
||||
}: UpgradeToProBannerProps) {
|
||||
const isOwner = useIsCurrentUserOwner();
|
||||
const { openAlertDialog } = useDialog();
|
||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleTransferDialogOpen = () => setTransferProjectDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{ backgroundColor: 'primary.light' }}
|
||||
@@ -51,29 +50,7 @@ export default function UpgradeToProBanner({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 space-y-2 lg:flex-row lg:items-center lg:space-x-2 lg:space-y-0">
|
||||
<Button
|
||||
className="max-w-xs lg:w-auto"
|
||||
onClick={() => {
|
||||
if (isOwner) {
|
||||
setTransferProjectDialogOpen(true);
|
||||
} else {
|
||||
openAlertDialog({
|
||||
title: "You can't migrate this project",
|
||||
payload: (
|
||||
<Text variant="subtitle1" component="span">
|
||||
Ask an owner of this organization to migrate the project.
|
||||
</Text>
|
||||
),
|
||||
props: {
|
||||
secondaryButtonText: 'I understand',
|
||||
hidePrimaryAction: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Transfer Project
|
||||
</Button>
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
@@ -40,6 +41,8 @@ export default function OrgPagesComboBox() {
|
||||
asPath,
|
||||
} = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
const orgPageFromUrl = pathSegments[3] || null;
|
||||
|
||||
@@ -64,7 +67,7 @@ export default function OrgPagesComboBox() {
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTrigger disabled={!isPlatform} asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/v3/popover';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useMemo, useState, type ReactElement } from 'react';
|
||||
@@ -40,88 +41,10 @@ type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
icon: ReactElement;
|
||||
disabled: boolean;
|
||||
};
|
||||
|
||||
const projectPages = [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
icon: <HomeIcon className="h-4 w-4" />,
|
||||
slug: '',
|
||||
},
|
||||
{
|
||||
label: 'Database',
|
||||
value: 'database',
|
||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||
slug: '/database/browser/default',
|
||||
},
|
||||
{
|
||||
label: 'GraphQL',
|
||||
value: 'graphql',
|
||||
icon: <GraphQLIcon className="h-4 w-4" />,
|
||||
slug: 'graphql',
|
||||
},
|
||||
{
|
||||
label: 'Hasura',
|
||||
value: 'hasura',
|
||||
icon: <HasuraIcon className="h-4 w-4" />,
|
||||
slug: 'hasura',
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
value: 'users',
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
slug: 'users',
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
value: 'storage',
|
||||
icon: <StorageIcon className="h-4 w-4" />,
|
||||
slug: 'storage',
|
||||
},
|
||||
{
|
||||
label: 'Run',
|
||||
value: 'run',
|
||||
icon: <ServicesIcon className="h-4 w-4" />,
|
||||
slug: 'run',
|
||||
},
|
||||
{
|
||||
label: 'AI',
|
||||
value: 'ai',
|
||||
icon: <AIIcon className="h-4 w-4" />,
|
||||
slug: 'ai/auto-embeddings',
|
||||
},
|
||||
{
|
||||
label: 'Deployments',
|
||||
value: 'deployments',
|
||||
icon: <RocketIcon className="h-4 w-4" />,
|
||||
slug: 'deployments',
|
||||
},
|
||||
{
|
||||
label: 'Backups',
|
||||
value: 'backups',
|
||||
icon: <CloudIcon className="h-4 w-4" />,
|
||||
slug: 'backups',
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 'logs',
|
||||
icon: <FileTextIcon className="h-4 w-4" />,
|
||||
slug: 'logs',
|
||||
},
|
||||
{
|
||||
label: 'Metrics',
|
||||
value: 'metrics',
|
||||
icon: <GaugeIcon className="h-4 w-4" />,
|
||||
slug: 'metrics',
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
icon: <CogIcon className="h-4 w-4" />,
|
||||
slug: 'settings',
|
||||
},
|
||||
];
|
||||
type SelectedOption = Omit<Option, 'disabled'>;
|
||||
|
||||
export default function ProjectPagesComboBox() {
|
||||
const {
|
||||
@@ -130,6 +53,105 @@ export default function ProjectPagesComboBox() {
|
||||
asPath,
|
||||
} = useRouter();
|
||||
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const projectPages = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: 'Overview',
|
||||
value: 'overview',
|
||||
icon: <HomeIcon className="h-4 w-4" />,
|
||||
slug: '',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Database',
|
||||
value: 'database',
|
||||
icon: <DatabaseIcon className="h-4 w-4" />,
|
||||
slug: '/database/browser/default',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'GraphQL',
|
||||
value: 'graphql',
|
||||
icon: <GraphQLIcon className="h-4 w-4" />,
|
||||
slug: 'graphql',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Hasura',
|
||||
value: 'hasura',
|
||||
icon: <HasuraIcon className="h-4 w-4" />,
|
||||
slug: 'hasura',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
value: 'users',
|
||||
icon: <UserIcon className="h-4 w-4" />,
|
||||
slug: 'users',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
value: 'storage',
|
||||
icon: <StorageIcon className="h-4 w-4" />,
|
||||
slug: 'storage',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Run',
|
||||
value: 'run',
|
||||
icon: <ServicesIcon className="h-4 w-4" />,
|
||||
slug: 'run',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'AI',
|
||||
value: 'ai',
|
||||
icon: <AIIcon className="h-4 w-4" />,
|
||||
slug: 'ai/auto-embeddings',
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
label: 'Deployments',
|
||||
value: 'deployments',
|
||||
icon: <RocketIcon className="h-4 w-4" />,
|
||||
slug: 'deployments',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Backups',
|
||||
value: 'backups',
|
||||
icon: <CloudIcon className="h-4 w-4" />,
|
||||
slug: 'backups',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Logs',
|
||||
value: 'logs',
|
||||
icon: <FileTextIcon className="h-4 w-4" />,
|
||||
slug: 'logs',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Metrics',
|
||||
value: 'metrics',
|
||||
icon: <GaugeIcon className="h-4 w-4" />,
|
||||
slug: 'metrics',
|
||||
disabled: !isPlatform,
|
||||
},
|
||||
{
|
||||
label: 'Settings',
|
||||
value: 'settings',
|
||||
icon: <CogIcon className="h-4 w-4" />,
|
||||
slug: 'settings',
|
||||
disabled: false,
|
||||
},
|
||||
],
|
||||
[isPlatform],
|
||||
);
|
||||
|
||||
const pathSegments = useMemo(() => asPath.split('/'), [asPath]);
|
||||
const projectPageFromUrl = appSubdomain
|
||||
? pathSegments[5] || 'overview'
|
||||
@@ -137,9 +159,8 @@ export default function ProjectPagesComboBox() {
|
||||
const selectedProjectPageFromUrl = projectPages.find(
|
||||
(item) => item.value === projectPageFromUrl,
|
||||
);
|
||||
const [selectedProjectPage, setSelectedProjectPage] = useState<Option | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProjectPage, setSelectedProjectPage] =
|
||||
useState<SelectedOption | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProjectPageFromUrl) {
|
||||
@@ -155,6 +176,7 @@ export default function ProjectPagesComboBox() {
|
||||
label: app.label,
|
||||
value: app.slug,
|
||||
icon: app.icon,
|
||||
disabled: app.disabled,
|
||||
}));
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -188,6 +210,7 @@ export default function ProjectPagesComboBox() {
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.label}
|
||||
disabled={option.disabled}
|
||||
onSelect={() => {
|
||||
setSelectedProjectPage(option);
|
||||
setOpen(false);
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRo
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
RemoteAppGetUsersAndAuthRolesDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -116,7 +116,7 @@ export default function EditUserForm({
|
||||
|
||||
const [updateUser] = useUpdateRemoteAppUserMutation({
|
||||
client: remoteProjectGQLClient,
|
||||
refetchQueries: [RemoteAppGetUsersDocument],
|
||||
refetchQueries: [RemoteAppGetUsersAndAuthRolesDocument],
|
||||
});
|
||||
|
||||
const form = useForm<EditUserFormValues>({
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/v2/Input';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -26,7 +26,7 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
/**
|
||||
* The selected user.
|
||||
*/
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
user: RemoteAppGetUsersAndAuthRolesQuery['users'][0];
|
||||
}
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
|
||||
@@ -175,7 +175,23 @@ function CreateOrgForm({ plans, onSubmit, onCancel }: CreateOrgFormProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CreateOrgDialog() {
|
||||
interface CreateOrgDialogProps {
|
||||
hideNewOrgButton?: boolean;
|
||||
isOpen?: boolean;
|
||||
onOpenStateChange?: (newState: boolean) => void;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
function isPropSet(prop: any) {
|
||||
return prop !== undefined;
|
||||
}
|
||||
|
||||
export default function CreateOrgDialog({
|
||||
hideNewOrgButton,
|
||||
isOpen,
|
||||
onOpenStateChange,
|
||||
redirectUrl,
|
||||
}: CreateOrgDialogProps) {
|
||||
const { maintenanceActive } = useUI();
|
||||
const user = useUserData();
|
||||
const isPlatform = useIsPlatform();
|
||||
@@ -186,6 +202,16 @@ export default function CreateOrgDialog() {
|
||||
const [createOrganizationRequest] = useCreateOrganizationRequestMutation();
|
||||
const [stripeClientSecret, setStripeClientSecret] = useState('');
|
||||
|
||||
const handleOpenChange = (newOpenState: boolean) => {
|
||||
const controlledFromOutSide =
|
||||
isPropSet(isOpen) && isPropSet(onOpenStateChange);
|
||||
if (controlledFromOutSide) {
|
||||
onOpenStateChange(newOpenState);
|
||||
} else {
|
||||
setOpen(newOpenState);
|
||||
}
|
||||
};
|
||||
|
||||
const createOrg = async ({
|
||||
name,
|
||||
plan,
|
||||
@@ -195,16 +221,17 @@ export default function CreateOrgDialog() {
|
||||
}) => {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const defaultRedirectUrl = `${window.location.origin}/orgs/verify`;
|
||||
|
||||
const {
|
||||
data: { billingCreateOrganizationRequest: clientSecret },
|
||||
} = await createOrganizationRequest({
|
||||
variables: {
|
||||
organizationName: name,
|
||||
planID: plan,
|
||||
redirectURL: `${window.location.origin}/orgs/verify`,
|
||||
redirectURL: redirectUrl ?? defaultRedirectUrl,
|
||||
},
|
||||
});
|
||||
|
||||
setStripeClientSecret(clientSecret);
|
||||
},
|
||||
{
|
||||
@@ -224,20 +251,22 @@ export default function CreateOrgDialog() {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={maintenanceActive}
|
||||
className={cn(
|
||||
'flex h-8 w-full flex-row justify-start gap-3 px-2',
|
||||
'bg-background text-foreground hover:bg-accent dark:hover:bg-muted',
|
||||
)}
|
||||
onClick={() => setStripeClientSecret('')}
|
||||
>
|
||||
<Plus className="h-4 w-4 font-bold" strokeWidth={3} />
|
||||
New Organization
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<Dialog open={isOpen ?? open} onOpenChange={handleOpenChange}>
|
||||
{!hideNewOrgButton && (
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
disabled={maintenanceActive}
|
||||
className={cn(
|
||||
'flex h-8 w-full flex-row justify-start gap-3 px-2',
|
||||
'bg-background text-foreground hover:bg-accent dark:hover:bg-muted',
|
||||
)}
|
||||
onClick={() => setStripeClientSecret('')}
|
||||
>
|
||||
<Plus className="h-4 w-4 font-bold" strokeWidth={3} />
|
||||
New Organization
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
)}
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'text-foreground sm:max-w-xl',
|
||||
@@ -264,7 +293,7 @@ export default function CreateOrgDialog() {
|
||||
<CreateOrgForm
|
||||
plans={data?.plans}
|
||||
onSubmit={createOrg}
|
||||
onCancel={() => setOpen(false)}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
/>
|
||||
)}
|
||||
{!loading && stripeClientSecret && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { CheckoutStatus } from '@/utils/__generated__/graphql';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
status: CheckoutStatus | null;
|
||||
successMessage: string;
|
||||
loadingMessage: string;
|
||||
errorMessage: string;
|
||||
pendingMessage: string;
|
||||
}
|
||||
|
||||
function FinishOrgCreationProcess({
|
||||
loading,
|
||||
status,
|
||||
successMessage,
|
||||
loadingMessage,
|
||||
errorMessage,
|
||||
pendingMessage,
|
||||
}: Props) {
|
||||
let message: string | undefined;
|
||||
|
||||
switch (status) {
|
||||
case CheckoutStatus.Completed: {
|
||||
message = successMessage;
|
||||
break;
|
||||
}
|
||||
case CheckoutStatus.Expired: {
|
||||
message = errorMessage;
|
||||
break;
|
||||
}
|
||||
case CheckoutStatus.Open: {
|
||||
message = pendingMessage;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message = loadingMessage;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-auto overflow-x-hidden">
|
||||
<div className="flex h-full w-full flex-col items-center justify-center space-y-2">
|
||||
{(loading || status === CheckoutStatus.Completed) && (
|
||||
<ActivityIndicator circularProgressProps={{ className: 'w-6 h-6' }} />
|
||||
)}
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(FinishOrgCreationProcess);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as FinishOrgCreationProcess } from './FinishOrgCreationProcess';
|
||||
@@ -0,0 +1,24 @@
|
||||
import { FinishOrgCreationProcess } from '@/features/orgs/components/common/FinishOrgCreationProcess';
|
||||
import { useFinishOrgCreation } from '@/features/orgs/hooks/useFinishOrgCreation';
|
||||
import { type FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||
|
||||
interface Props {
|
||||
onCompleted: FinishOrgCreationOnCompletedCb;
|
||||
onError: () => void;
|
||||
}
|
||||
|
||||
function FinishOrgCreation({ onCompleted, onError }: Props) {
|
||||
const [loading, status] = useFinishOrgCreation({ onCompleted, onError });
|
||||
return (
|
||||
<FinishOrgCreationProcess
|
||||
loading={loading}
|
||||
status={status}
|
||||
loadingMessage="Processing new organization request"
|
||||
successMessage="Organization created successfully."
|
||||
pendingMessage="Organization creation is pending..."
|
||||
errorMessage="Error occurred while creating the organization. Please try again."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default FinishOrgCreation;
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
mockMatchMediaValue,
|
||||
mockOrganization,
|
||||
mockOrganizations,
|
||||
mockOrganizationsWithNewOrg,
|
||||
newOrg,
|
||||
} from '@/tests/mocks';
|
||||
import { getOrganization } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||
import { getProjectQuery } from '@/tests/msw/mocks/graphql/getProjectQuery';
|
||||
import { prefetchNewAppQuery } from '@/tests/msw/mocks/graphql/prefetchNewAppQuery';
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import {
|
||||
createGraphqlMockResolver,
|
||||
fireEvent,
|
||||
mockPointerEvent,
|
||||
queryClient,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '@/tests/orgs/testUtils';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { setupServer } from 'msw/node';
|
||||
import { useState } from 'react';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import TransferProjectDialog from './TransferProjectDialog';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
mockPointerEvent();
|
||||
|
||||
const getUseRouterObject = (session_id?: string) => ({
|
||||
basePath: '',
|
||||
pathname: '/orgs/xyz/projects/test-project',
|
||||
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
|
||||
asPath: '/orgs/xyz/projects/test-project',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {
|
||||
orgSlug: 'xyz',
|
||||
appSubdomain: 'test-project',
|
||||
session_id,
|
||||
},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
back: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
beforePopState: vi.fn(),
|
||||
events: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
isFallback: false,
|
||||
});
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useRouter: vi.fn(),
|
||||
useOrgs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/orgs/projects/hooks/useOrgs', async () => {
|
||||
const actualUseOrgs = await vi.importActual<any>(
|
||||
'@/features/orgs/projects/hooks/useOrgs',
|
||||
);
|
||||
return {
|
||||
...actualUseOrgs,
|
||||
useOrgs: mocks.useOrgs,
|
||||
};
|
||||
});
|
||||
|
||||
const postOrganizationRequestResolver = createGraphqlMockResolver(
|
||||
'postOrganizationRequest',
|
||||
'mutation',
|
||||
);
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: mocks.useRouter,
|
||||
}));
|
||||
|
||||
export function DialogWrapper({
|
||||
defaultOpen = true,
|
||||
}: {
|
||||
defaultOpen?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return <TransferProjectDialog open={open} setOpen={setOpen} />;
|
||||
}
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
mocks.useRouter.mockRestore();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('opens create org dialog when selecting "create new org" and closes transfer dialog', async () => {
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject());
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(getProjectQuery);
|
||||
server.use(getOrganization);
|
||||
mocks.useOrgs.mockImplementation(() => ({
|
||||
orgs: mockOrganizations,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
}));
|
||||
server.use(prefetchNewAppQuery);
|
||||
|
||||
render(<DialogWrapper />);
|
||||
const organizationCombobox = await screen.findByRole('combobox', {
|
||||
name: /Organization/i,
|
||||
});
|
||||
|
||||
expect(organizationCombobox).toBeInTheDocument();
|
||||
|
||||
await user.click(organizationCombobox);
|
||||
|
||||
const newOrgOption = await screen.findByRole('option', {
|
||||
name: 'New Organization',
|
||||
});
|
||||
await user.click(newOrgOption);
|
||||
expect(organizationCombobox).toHaveTextContent('New Organization');
|
||||
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
expect(submitButton).toHaveTextContent('Continue');
|
||||
|
||||
fireEvent(
|
||||
submitButton,
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const newOrgTitle = await screen.findByText('New Organization');
|
||||
expect(newOrgTitle).toBeInTheDocument();
|
||||
const closeButton = await screen.findByText('Close');
|
||||
fireEvent(
|
||||
closeButton,
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(newOrgTitle).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submitButtonAfterClosingNewOrgDialog =
|
||||
await screen.findByText('Continue');
|
||||
await waitFor(() => {
|
||||
expect(submitButtonAfterClosingNewOrgDialog).toHaveTextContent('Continue');
|
||||
});
|
||||
});
|
||||
|
||||
test(`transfer dialog opens automatically when there is a session_id and selects the ${newOrg.name} from the dropdown`, async () => {
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject('session_id'));
|
||||
server.use(getProjectQuery);
|
||||
server.use(getOrganization);
|
||||
mocks.useOrgs.mockImplementation(() => ({
|
||||
orgs: mockOrganizations,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
}));
|
||||
server.use(prefetchNewAppQuery);
|
||||
server.use(postOrganizationRequestResolver.handler);
|
||||
|
||||
render(<DialogWrapper defaultOpen={false} />);
|
||||
const processingNewOrgText = await screen.findByText(
|
||||
'Processing new organization request',
|
||||
);
|
||||
|
||||
expect(processingNewOrgText).toBeInTheDocument();
|
||||
|
||||
const closeButton = await screen.findByText('Close');
|
||||
|
||||
fireEvent(
|
||||
closeButton,
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {});
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
|
||||
postOrganizationRequestResolver.resolve({
|
||||
billingPostOrganizationRequest: {
|
||||
Status: 'COMPLETED',
|
||||
Slug: newOrg.slug,
|
||||
ClientSecret: null,
|
||||
__typename: 'PostOrganizationRequestResponse',
|
||||
},
|
||||
});
|
||||
|
||||
mocks.useOrgs.mockImplementation(() => ({
|
||||
orgs: mockOrganizationsWithNewOrg,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
}));
|
||||
|
||||
const organizationCombobox = await screen.findByRole('combobox', {
|
||||
name: /Organization/i,
|
||||
});
|
||||
|
||||
expect(organizationCombobox).toHaveTextContent(newOrg.name);
|
||||
|
||||
const submitButton = await screen.findByText('Transfer');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,6 +8,8 @@ import {
|
||||
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -25,20 +25,26 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
import FinishOrgCreation from '@/features/orgs/components/common/TransferProjectDialog/FinishOrgCreation';
|
||||
import CreateOrgDialog from '@/features/orgs/components/CreateOrgFormDialog/CreateOrgFormDialog';
|
||||
import type { FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||
import { useOrgs, type Org } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, isNotEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
Organization_Members_Role_Enum,
|
||||
useBillingTransferAppMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUserId } from '@nhost/nextjs';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CREATE_NEW_ORG = 'createNewOrg';
|
||||
interface TransferProjectDialogProps {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
@@ -52,11 +58,21 @@ export default function TransferProjectDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: TransferProjectDialogProps) {
|
||||
const { push } = useRouter();
|
||||
const { push, asPath, query, replace, pathname } = useRouter();
|
||||
const { session_id, test, ...remainingQuery } = query;
|
||||
const currentUserId = useUserId();
|
||||
const { project, loading: projectLoading } = useProject();
|
||||
const { orgs, currentOrg, loading: orgsLoading } = useOrgs();
|
||||
const {
|
||||
orgs,
|
||||
currentOrg,
|
||||
loading: orgsLoading,
|
||||
refetch: refetchOrgs,
|
||||
} = useOrgs();
|
||||
const [transferProject] = useBillingTransferAppMutation();
|
||||
const [showCreateOrgModal, setShowCreateOrgModal] = useState(false);
|
||||
const [finishOrgCreation, setFinishOrgCreation] = useState(false);
|
||||
const [preventClose, setPreventClose] = useState(false);
|
||||
const [newOrgSlug, setNewOrgSlug] = useState<string | undefined>();
|
||||
|
||||
const form = useForm<z.infer<typeof transferProjectFormSchema>>({
|
||||
resolver: zodResolver(transferProjectFormSchema),
|
||||
@@ -65,29 +81,62 @@ export default function TransferProjectDialog({
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (session_id) {
|
||||
setOpen(true);
|
||||
setFinishOrgCreation(true);
|
||||
setPreventClose(true);
|
||||
}
|
||||
}, [session_id, setOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNotEmptyValue(newOrgSlug)) {
|
||||
const newOrg = orgs.find((org) => org.slug === newOrgSlug);
|
||||
if (newOrg) {
|
||||
form.setValue('organization', newOrg?.id, { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
}, [newOrgSlug, orgs, form]);
|
||||
|
||||
const createNewFormSelected = form.watch('organization') === CREATE_NEW_ORG;
|
||||
const submitButtonText = createNewFormSelected ? 'Continue' : 'Transfer';
|
||||
|
||||
const path = asPath.split('?')[0];
|
||||
const redirectUrl = `${window.location.origin}${path}`;
|
||||
|
||||
const handleCreateDialogOpenStateChange = (newState: boolean) => {
|
||||
setShowCreateOrgModal(newState);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const onSubmit = async (
|
||||
values: z.infer<typeof transferProjectFormSchema>,
|
||||
) => {
|
||||
const { organization } = values;
|
||||
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await transferProject({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
organizationID: organization,
|
||||
},
|
||||
});
|
||||
if (organization === CREATE_NEW_ORG) {
|
||||
setShowCreateOrgModal(true);
|
||||
setOpen(false);
|
||||
} else {
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await transferProject({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
organizationID: organization,
|
||||
},
|
||||
});
|
||||
|
||||
const targetOrg = orgs.find((o) => o.id === organization);
|
||||
await push(`/orgs/${targetOrg.slug}/projects`);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Transferring project...',
|
||||
successMessage: 'Project transferred successfully!',
|
||||
errorMessage: 'Error transferring project. Please try again.',
|
||||
},
|
||||
);
|
||||
const targetOrg = orgs.find((o) => o.id === organization);
|
||||
await push(`/orgs/${targetOrg.slug}/projects`);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Transferring project...',
|
||||
successMessage: 'Project transferred successfully!',
|
||||
errorMessage: 'Error transferring project. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const isUserAdminOfOrg = (org: Org, userId: string) =>
|
||||
@@ -97,103 +146,161 @@ export default function TransferProjectDialog({
|
||||
member.user.id === userId,
|
||||
);
|
||||
|
||||
const removeSessionIdFromQuery = () => {
|
||||
replace({ pathname, query: remainingQuery }, undefined, { shallow: true });
|
||||
};
|
||||
|
||||
const handleFinishOrgCreationCompleted: FinishOrgCreationOnCompletedCb =
|
||||
async (data) => {
|
||||
const { Slug } = data;
|
||||
|
||||
await refetchOrgs();
|
||||
setNewOrgSlug(Slug);
|
||||
setFinishOrgCreation(false);
|
||||
removeSessionIdFromQuery();
|
||||
setPreventClose(false);
|
||||
};
|
||||
|
||||
const handleTransferProjectDialogOpenChange = (newValue: boolean) => {
|
||||
if (preventClose) {
|
||||
return;
|
||||
}
|
||||
if (!newValue) {
|
||||
setNewOrgSlug(undefined);
|
||||
}
|
||||
form.reset();
|
||||
setOpen(newValue);
|
||||
};
|
||||
|
||||
if (projectLoading || orgsLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
form.reset();
|
||||
setOpen(value);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="z-[9999] text-foreground sm:max-w-xl">
|
||||
<DialogHeader className="flex gap-2">
|
||||
<DialogTitle>
|
||||
Move the current project to a different organization.
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
To transfer a project between organizations, you must be an{' '}
|
||||
<span className="font-bold">ADMIN</span> in both.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Organization" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{orgs.map((org) => (
|
||||
<SelectItem
|
||||
key={org.id}
|
||||
value={org.id}
|
||||
disabled={
|
||||
org.plan.isFree || // disable the personal org
|
||||
org.id === currentOrg.id || // disable the current org as it can't be a destination org
|
||||
!isUserAdminOfOrg(org, currentUserId) // disable orgs that the current user is not admin of
|
||||
}
|
||||
>
|
||||
{org.name}
|
||||
<Badge
|
||||
variant={org.plan.isFree ? 'outline' : 'default'}
|
||||
className={cn(
|
||||
org.plan.isFree ? 'bg-muted' : '',
|
||||
'hover:none ml-2 h-5 px-[6px] text-[10px]',
|
||||
)}
|
||||
>
|
||||
{org.plan.name}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleTransferProjectDialogOpenChange}>
|
||||
<DialogContent className="z-[9999] text-foreground sm:max-w-xl">
|
||||
<DialogHeader className="flex gap-2">
|
||||
<DialogTitle>
|
||||
Move the current project to a different organization.{' '}
|
||||
</DialogTitle>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={form.formState.isSubmitting}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
{!finishOrgCreation && (
|
||||
<DialogDescription>
|
||||
To transfer a project between organizations, you must be an{' '}
|
||||
<span className="font-bold">ADMIN</span> in both.
|
||||
<br />
|
||||
When transferred to a new organization, the project will adopt
|
||||
that organization’s plan.
|
||||
</DialogDescription>
|
||||
)}
|
||||
</DialogHeader>
|
||||
{finishOrgCreation ? (
|
||||
<FinishOrgCreation
|
||||
onCompleted={handleFinishOrgCreationCompleted}
|
||||
onError={() => setPreventClose(false)}
|
||||
/>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
form.formState.isSubmitting || !form.formState.isDirty
|
||||
}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
'Transfer'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="organization"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Organization</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Organization" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{orgs.map((org) => (
|
||||
<SelectItem
|
||||
key={org.id}
|
||||
value={org.id}
|
||||
disabled={
|
||||
org.plan.isFree || // disable the personal org
|
||||
org.id === currentOrg.id || // disable the current org as it can't be a destination org
|
||||
!isUserAdminOfOrg(org, currentUserId) // disable orgs that the current user is not admin of
|
||||
}
|
||||
>
|
||||
{org.name}
|
||||
<Badge
|
||||
variant={
|
||||
org.plan.isFree ? 'outline' : 'default'
|
||||
}
|
||||
className={cn(
|
||||
org.plan.isFree ? 'bg-muted' : '',
|
||||
'hover:none ml-2 h-5 px-[6px] text-[10px]',
|
||||
)}
|
||||
>
|
||||
{org.plan.name}
|
||||
</Badge>
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem
|
||||
key={CREATE_NEW_ORG}
|
||||
value={CREATE_NEW_ORG}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Plus
|
||||
className="h-4 w-4 font-bold"
|
||||
strokeWidth={3}
|
||||
/>{' '}
|
||||
<span>New Organization</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={form.formState.isSubmitting || preventClose}
|
||||
onClick={() => {
|
||||
form.reset();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={
|
||||
form.formState.isSubmitting || !form.formState.isDirty
|
||||
}
|
||||
>
|
||||
{form.formState.isSubmitting ? (
|
||||
<ActivityIndicator />
|
||||
) : (
|
||||
submitButtonText
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreateOrgDialog
|
||||
hideNewOrgButton
|
||||
isOpen={showCreateOrgModal}
|
||||
onOpenStateChange={handleCreateDialogOpenStateChange}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { mockMatchMediaValue } from '@/tests/mocks';
|
||||
import { getOrganizations } from '@/tests/msw/mocks/graphql/getOrganizationQuery';
|
||||
|
||||
import tokenQuery from '@/tests/msw/mocks/rest/tokenQuery';
|
||||
import { mockSession } from '@/tests/orgs/mocks';
|
||||
import { queryClient, render, waitFor } from '@/tests/orgs/testUtils';
|
||||
import { CheckoutStatus } from '@/utils/__generated__/graphql';
|
||||
|
||||
import { setupServer } from 'msw/node';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import NotificationsTray from './NotificationsTray';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
export const getUseRouterObject = (session_id?: string) => ({
|
||||
basePath: '',
|
||||
pathname: '/orgs/xyz/projects/test-project',
|
||||
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
|
||||
asPath: '/orgs/xyz/projects/test-project',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isPreview: false,
|
||||
query: {
|
||||
orgSlug: 'xyz',
|
||||
appSubdomain: 'test-project',
|
||||
session_id,
|
||||
},
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
back: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
beforePopState: vi.fn(),
|
||||
events: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
isFallback: false,
|
||||
});
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useRouter: vi.fn(),
|
||||
useOrganizationNewRequestsLazyQuery: vi.fn(),
|
||||
usePostOrganizationRequestMutation: vi.fn(),
|
||||
useOrganizationMemberInvitesLazyQuery: vi.fn(),
|
||||
fetchPostOrganizationResponseMock: vi.fn(),
|
||||
userData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: mocks.useRouter,
|
||||
}));
|
||||
|
||||
vi.mock('@nhost/nextjs', async () => {
|
||||
const actualNhostNextjs = await vi.importActual<any>('@nhost/nextjs');
|
||||
return {
|
||||
...actualNhostNextjs,
|
||||
userData: mocks.userData,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/utils/__generated__/graphql', async () => {
|
||||
const actual = await vi.importActual<any>('@/utils/__generated__/graphql');
|
||||
return {
|
||||
...actual,
|
||||
useOrganizationNewRequestsLazyQuery:
|
||||
mocks.useOrganizationNewRequestsLazyQuery,
|
||||
usePostOrganizationRequestMutation:
|
||||
mocks.usePostOrganizationRequestMutation,
|
||||
useOrganizationMemberInvitesLazyQuery:
|
||||
mocks.useOrganizationMemberInvitesLazyQuery,
|
||||
};
|
||||
});
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NEXT_PUBLIC_NHOST_PLATFORM = 'true';
|
||||
process.env.NEXT_PUBLIC_ENV = 'production';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
||||
|
||||
const fetchOrganizationMemberInvitesMock = () => [
|
||||
async () => ({ data: { organizationMemberInvites: [] } }),
|
||||
{
|
||||
loading: true,
|
||||
refetch: vi.fn(),
|
||||
data: { organizationMemberInvites: [] },
|
||||
},
|
||||
];
|
||||
|
||||
const fetchOrganizationNewRequestsResponseMock = async () => ({
|
||||
data: {
|
||||
organizationNewRequests: [
|
||||
{
|
||||
id: 'org-request-id-1',
|
||||
sessionID: 'session-id-1',
|
||||
__typename: 'organization_new_request',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const fetchPostOrganizationResponseMock = vi.fn();
|
||||
|
||||
test('if there is NO session_id in the url the billingPostOrganizationRequest is fetched from the server', async () => {
|
||||
server.use(getOrganizations);
|
||||
mocks.useOrganizationMemberInvitesLazyQuery.mockImplementation(
|
||||
fetchOrganizationMemberInvitesMock,
|
||||
);
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject());
|
||||
mocks.userData.mockImplementation(() => mockSession.user);
|
||||
mocks.useOrganizationNewRequestsLazyQuery.mockImplementation(() => [
|
||||
fetchOrganizationNewRequestsResponseMock,
|
||||
]);
|
||||
mocks.usePostOrganizationRequestMutation.mockImplementation(() => [
|
||||
fetchPostOrganizationResponseMock.mockImplementation(() => ({
|
||||
data: {
|
||||
billingPostOrganizationRequest: {
|
||||
Status: CheckoutStatus.Open,
|
||||
Slug: 'newOrgSlug',
|
||||
ClientSecret: 'very_secret_secret',
|
||||
__typename: 'PostOrganizationRequestResponse',
|
||||
},
|
||||
},
|
||||
})),
|
||||
]);
|
||||
|
||||
render(<NotificationsTray />);
|
||||
await waitFor(() => {
|
||||
/* Wait for the component to be update */
|
||||
});
|
||||
expect(fetchPostOrganizationResponseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if there is a session_id in the url the billingPostOrganizationRequest is NOT fetched from the server ', async () => {
|
||||
server.use(getOrganizations);
|
||||
mocks.useOrganizationMemberInvitesLazyQuery.mockImplementation(
|
||||
fetchOrganizationMemberInvitesMock,
|
||||
);
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject('SESSION_ID'));
|
||||
mocks.userData.mockImplementation(() => mockSession.user);
|
||||
mocks.useOrganizationNewRequestsLazyQuery.mockImplementation(() => [
|
||||
fetchOrganizationNewRequestsResponseMock,
|
||||
]);
|
||||
mocks.usePostOrganizationRequestMutation.mockImplementation(() => [
|
||||
fetchPostOrganizationResponseMock,
|
||||
]);
|
||||
|
||||
render(<NotificationsTray />);
|
||||
await waitFor(() => {
|
||||
/* Wait for the component to be update */
|
||||
});
|
||||
expect(fetchPostOrganizationResponseMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { StripeEmbeddedForm } from '@/features/orgs/components/StripeEmbeddedForm';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
CheckoutStatus,
|
||||
useDeleteOrganizationMemberInviteMutation,
|
||||
@@ -38,7 +39,8 @@ type Invite = OrganizationMemberInvitesQuery['organizationMemberInvites'][0];
|
||||
|
||||
export default function NotificationsTray() {
|
||||
const userData = useUserData();
|
||||
const { asPath, route, push } = useRouter();
|
||||
const { asPath, route, push, query } = useRouter();
|
||||
const { session_id } = query;
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -76,7 +78,6 @@ export default function NotificationsTray() {
|
||||
userID: userData.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (organizationNewRequests.length > 0) {
|
||||
const { sessionID } = organizationNewRequests.at(0);
|
||||
|
||||
@@ -109,10 +110,20 @@ export default function NotificationsTray() {
|
||||
}
|
||||
};
|
||||
|
||||
if (userData && !['/', '/orgs/verify'].includes(route)) {
|
||||
if (
|
||||
userData &&
|
||||
!['/', '/orgs/verify'].includes(route) &&
|
||||
isEmptyValue(session_id)
|
||||
) {
|
||||
checkForPendingOrgRequests();
|
||||
}
|
||||
}, [route, userData, getOrganizationNewRequests, postOrganizationRequest]);
|
||||
}, [
|
||||
route,
|
||||
userData,
|
||||
getOrganizationNewRequests,
|
||||
postOrganizationRequest,
|
||||
session_id,
|
||||
]);
|
||||
|
||||
const [acceptInvite] = useOrganizationMemberInviteAcceptMutation();
|
||||
const [deleteInvite] = useDeleteOrganizationMemberInviteMutation();
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useFinishOrgCreation } from './useFinishOrgCreation';
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
CheckoutStatus,
|
||||
type PostOrganizationRequestMutation,
|
||||
usePostOrganizationRequestMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export type FinishOrgCreationOnCompletedCb = (
|
||||
data: PostOrganizationRequestMutation['billingPostOrganizationRequest'],
|
||||
) => void;
|
||||
|
||||
interface UseFinishOrgCreationProps {
|
||||
onCompleted: FinishOrgCreationOnCompletedCb;
|
||||
onError?: () => void;
|
||||
}
|
||||
|
||||
function useFinishOrgCreation({
|
||||
onCompleted,
|
||||
onError,
|
||||
}: UseFinishOrgCreationProps): [boolean, CheckoutStatus] {
|
||||
const router = useRouter();
|
||||
const { session_id } = router.query;
|
||||
|
||||
const { isAuthenticated } = useAuthenticationStatus();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [postOrganizationRequest] = usePostOrganizationRequestMutation();
|
||||
const [status, setPostOrganizationRequestStatus] =
|
||||
useState<CheckoutStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function finishOrgCreation() {
|
||||
if (session_id && isAuthenticated) {
|
||||
setLoading(true);
|
||||
|
||||
execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const {
|
||||
data: { billingPostOrganizationRequest },
|
||||
} = await postOrganizationRequest({
|
||||
variables: {
|
||||
sessionID: session_id as string,
|
||||
},
|
||||
});
|
||||
|
||||
const { Status } = billingPostOrganizationRequest;
|
||||
|
||||
setLoading(false);
|
||||
setPostOrganizationRequestStatus(Status);
|
||||
|
||||
switch (Status) {
|
||||
case CheckoutStatus.Completed:
|
||||
onCompleted(billingPostOrganizationRequest);
|
||||
break;
|
||||
|
||||
case CheckoutStatus.Expired:
|
||||
onError();
|
||||
throw new Error('Request to create organization has expired');
|
||||
|
||||
case CheckoutStatus.Open:
|
||||
// TODO discuss what to do in this case
|
||||
onError();
|
||||
throw new Error(
|
||||
'Request to create organization with status "Open"',
|
||||
);
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Processing new organization request',
|
||||
successMessage:
|
||||
'The new organization has been created successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while creating the new organization.',
|
||||
onError,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
finishOrgCreation();
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session_id, isAuthenticated]);
|
||||
|
||||
return [loading, status];
|
||||
}
|
||||
|
||||
export default useFinishOrgCreation;
|
||||
@@ -24,7 +24,7 @@ import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRo
|
||||
import { type RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import {
|
||||
RemoteAppGetUsersDocument,
|
||||
RemoteAppGetUsersAndAuthRolesDocument,
|
||||
useGetProjectLocalesQuery,
|
||||
useGetRolesPermissionsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -120,7 +120,7 @@ export default function EditUserForm({
|
||||
|
||||
const [updateUser] = useUpdateRemoteAppUserMutation({
|
||||
client: remoteProjectGQLClient,
|
||||
refetchQueries: [RemoteAppGetUsersDocument],
|
||||
refetchQueries: [RemoteAppGetUsersAndAuthRolesDocument],
|
||||
});
|
||||
|
||||
const form = useForm<EditUserFormValues>({
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteAp
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import type { DialogFormProps } from '@/types/common';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import {
|
||||
useGetSignInMethodsQuery,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -26,7 +26,7 @@ export interface EditUserPasswordFormProps extends DialogFormProps {
|
||||
/**
|
||||
* The selected user.
|
||||
*/
|
||||
user: RemoteAppGetUsersQuery['users'][0];
|
||||
user: RemoteAppGetUsersAndAuthRolesQuery['users'][0];
|
||||
}
|
||||
|
||||
export default function EditUserPasswordForm({
|
||||
|
||||
@@ -15,15 +15,11 @@ import { Text } from '@/components/ui/v2/Text';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import type { EditUserFormValues } from '@/features/orgs/projects/authentication/users/components/EditUserForm';
|
||||
import { getReadableProviderName } from '@/features/orgs/projects/authentication/users/utils/getReadableProviderName';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useLocalMimirClient } from '@/features/orgs/projects/hooks/useLocalMimirClient';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
import type { RemoteAppUser } from '@/pages/orgs/[orgSlug]/projects/[appSubdomain]/users';
|
||||
import type { Role } from '@/types/application';
|
||||
import {
|
||||
useDeleteRemoteAppUserRolesMutation,
|
||||
useGetRolesPermissionsQuery,
|
||||
useInsertRemoteAppUserRolesMutation,
|
||||
useRemoteAppDeleteUserMutation,
|
||||
useUpdateRemoteAppUserMutation,
|
||||
@@ -33,7 +29,7 @@ import { formatDistance } from 'date-fns';
|
||||
import kebabCase from 'just-kebab-case';
|
||||
import dynamic from 'next/dynamic';
|
||||
import Image from 'next/image';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
const EditUserForm = dynamic(
|
||||
() =>
|
||||
@@ -59,14 +55,16 @@ export interface UsersBodyProps {
|
||||
* @example onSuccessfulAction={() => router.reload()}
|
||||
*/
|
||||
onSubmit?: () => Promise<any>;
|
||||
allAvailableProjectRoles: Role[];
|
||||
}
|
||||
|
||||
export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
export default function UsersBody({
|
||||
users,
|
||||
onSubmit,
|
||||
allAvailableProjectRoles,
|
||||
}: UsersBodyProps) {
|
||||
const theme = useTheme();
|
||||
const isPlatform = useIsPlatform();
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { openAlertDialog, openDrawer, closeDrawer } = useDialog();
|
||||
const { project } = useProject();
|
||||
const remoteProjectGQLClient = useRemoteApplicationGQLClient();
|
||||
|
||||
const [deleteUser] = useRemoteAppDeleteUserMutation({
|
||||
@@ -85,23 +83,6 @@ export default function UsersBody({ users, onSubmit }: UsersBodyProps) {
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
/**
|
||||
* We want to fetch the queries of the application on this page since we're
|
||||
* going to use once the user selects a user of their application; we use it
|
||||
* in the drawer form.
|
||||
*/
|
||||
const { data: dataRoles } = useGetRolesPermissionsQuery({
|
||||
variables: { appId: project?.id },
|
||||
...(!isPlatform ? { client: localMimirClient } : {}),
|
||||
});
|
||||
|
||||
const { allowed: allowedRoles } = dataRoles?.config?.auth?.user?.roles || {};
|
||||
|
||||
const allAvailableProjectRoles = useMemo(
|
||||
() => getUserRoles(allowedRoles),
|
||||
[allowedRoles],
|
||||
);
|
||||
|
||||
async function handleEditUser(
|
||||
values: EditUserFormValues,
|
||||
user: RemoteAppUser,
|
||||
|
||||
@@ -18,7 +18,24 @@ const ruleSchema = Yup.object().shape({
|
||||
});
|
||||
|
||||
const ruleGroupSchema = Yup.object().shape({
|
||||
operator: Yup.string().required('Please select an operator.'),
|
||||
operator: Yup.string().test(
|
||||
'operator',
|
||||
'Please select an operator.',
|
||||
(selectedOperator, ctx) => {
|
||||
// `from` is part of the Yup API, but it's not typed.
|
||||
// @ts-ignore
|
||||
const [, { value }] = ctx.from;
|
||||
if (
|
||||
value.filter &&
|
||||
Object.keys(value.filter).length > 0 &&
|
||||
!selectedOperator
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
),
|
||||
rules: Yup.array().of(ruleSchema),
|
||||
groups: Yup.array().of(Yup.lazy(() => ruleGroupSchema) as any),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { NhostIcon } from '@/components/presentational/NhostIcon';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { Alert } from '@/components/ui/v2/Alert';
|
||||
import { Box } from '@/components/ui/v2/Box';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Input } from '@/components/ui/v2/Input';
|
||||
import { InputAdornment } from '@/components/ui/v2/InputAdornment';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { UpgradeNotification } from '@/features/orgs/projects/common/components/UpgradeNotification';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { useAppState } from '@/features/orgs/projects/common/hooks/useAppState';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { DatabaseStorageCapacityWarning } from '@/features/orgs/projects/database/settings/components/DatabaseStorageCapacityWarning';
|
||||
@@ -23,7 +26,7 @@ import {
|
||||
} from '@/generated/graphql';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
|
||||
@@ -39,6 +42,57 @@ export type DatabaseStorageCapacityFormValues = Yup.InferType<
|
||||
typeof validationSchema
|
||||
>;
|
||||
|
||||
function UpgradeNotification() {
|
||||
const [transferProjectDialogOpen, setTransferProjectDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const handleTransferDialogOpen = () => setTransferProjectDialogOpen(true);
|
||||
|
||||
return (
|
||||
<Alert className="flex w-full flex-col justify-between gap-4 lg:flex-row">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col space-y-2 lg:flex-row lg:space-x-2 lg:space-y-0">
|
||||
<Text className="text-left">Available with</Text>
|
||||
<div className="flex flex-row space-x-2">
|
||||
<NhostIcon />
|
||||
<Text
|
||||
sx={{ color: 'primary.main' }}
|
||||
className="text-left font-semibold"
|
||||
>
|
||||
Nhost Pro & Team
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text component="span" className="max-w-[50ch] text-left">
|
||||
To unlock more storage capacity, transfer this project to a Pro or
|
||||
Team organization.
|
||||
</Text>
|
||||
</div>
|
||||
<Text className="flex flex-row items-center gap-4 self-end">
|
||||
<Link
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="whitespace-nowrap text-center font-medium"
|
||||
sx={{
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
>
|
||||
See all features
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
/>
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DatabaseStorageCapacity() {
|
||||
const isPlatform = useIsPlatform();
|
||||
const { org } = useCurrentOrg();
|
||||
@@ -46,6 +100,8 @@ export default function DatabaseStorageCapacity() {
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { project } = useProject();
|
||||
|
||||
const isFreeProject = !!org?.plan.isFree;
|
||||
|
||||
const {
|
||||
data,
|
||||
loading,
|
||||
@@ -172,9 +228,7 @@ export default function DatabaseStorageCapacity() {
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
{project.legacyPlan?.isFree && (
|
||||
<UpgradeNotification message="Unlock by upgrading your project to the Pro plan." />
|
||||
)}
|
||||
{isFreeProject && <UpgradeNotification />}
|
||||
<Box className="grid grid-flow-row lg:grid-cols-5">
|
||||
<Input
|
||||
{...register('capacity')}
|
||||
@@ -187,13 +241,13 @@ export default function DatabaseStorageCapacity() {
|
||||
</InputAdornment>
|
||||
}
|
||||
fullWidth
|
||||
disabled={project.legacyPlan?.isFree}
|
||||
disabled={isFreeProject}
|
||||
className="lg:col-span-2"
|
||||
error={Boolean(formState.errors.capacity?.message)}
|
||||
helperText={formState.errors.capacity?.message}
|
||||
/>
|
||||
</Box>
|
||||
{!project.legacyPlan?.isFree && (
|
||||
{!isFreeProject && (
|
||||
<DatabaseStorageCapacityWarning
|
||||
state={state}
|
||||
decreasingSize={decreasingSize}
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function LogsBody({ logsData, loading, error }: LogsBodyProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!logsData || logsData.logs?.length === 0) {
|
||||
if (logsData?.logs?.length === 0) {
|
||||
return (
|
||||
<LogsBodyCustomMessage>
|
||||
<Text className="truncate font-mono text-xs- font-normal">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { Form } from '@/components/form/Form';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import type { BoxProps } from '@/components/ui/v2/Box';
|
||||
@@ -12,14 +11,19 @@ import { Option } from '@/components/ui/v2/Option';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { LogsRangeSelector } from '@/features/orgs/projects/logs/components/LogsRangeSelector';
|
||||
import { AvailableLogsService } from '@/features/orgs/projects/logs/utils/constants/services';
|
||||
import {
|
||||
AvailableLogsService,
|
||||
LOGS_SERVICE_TO_LABEL,
|
||||
} from '@/features/orgs/projects/logs/utils/constants/services';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useGetServiceLabelValuesQuery } from '@/utils/__generated__/graphql';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import * as Yup from 'yup';
|
||||
import LogsServiceFilter from './LogsServiceFilter';
|
||||
|
||||
export const validationSchema = Yup.object({
|
||||
from: Yup.date(),
|
||||
@@ -49,42 +53,33 @@ export default function LogsHeader({
|
||||
}: LogsHeaderProps) {
|
||||
const { project } = useProject();
|
||||
|
||||
const [serviceLabels, setServiceLabels] = useState<
|
||||
{ label: string; value: string }[]
|
||||
>([]);
|
||||
const { data } = useGetServiceLabelValuesQuery({
|
||||
variables: { appID: project?.id },
|
||||
skip: !project?.id,
|
||||
});
|
||||
|
||||
const { data, loading: loadingServiceLabelValues } =
|
||||
useGetServiceLabelValuesQuery({
|
||||
variables: { appID: project?.id },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingServiceLabelValues) {
|
||||
const labels = data.getServiceLabelValues ?? [];
|
||||
setServiceLabels(labels.map((l) => ({ label: l, value: l })));
|
||||
const serviceOptions = useMemo(() => {
|
||||
if (isEmptyValue(data)) {
|
||||
return [];
|
||||
}
|
||||
}, [loadingServiceLabelValues, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingServiceLabelValues) {
|
||||
const labels = data.getServiceLabelValues ?? [];
|
||||
const options = [
|
||||
{
|
||||
label: LOGS_SERVICE_TO_LABEL[AvailableLogsService.ALL],
|
||||
value: AvailableLogsService.ALL,
|
||||
},
|
||||
...data.getServiceLabelValues.map((l) => ({
|
||||
label: LOGS_SERVICE_TO_LABEL[l] ?? l,
|
||||
value: l,
|
||||
})),
|
||||
];
|
||||
|
||||
const labelMappings = {
|
||||
'hasura-auth': 'Auth',
|
||||
'hasura-storage': 'Storage',
|
||||
postgres: 'Postgres',
|
||||
functions: 'Functions',
|
||||
hasura: 'Hasura',
|
||||
grafana: 'Grafana',
|
||||
'job-backup': 'Backup Jobs',
|
||||
ai: 'AI',
|
||||
};
|
||||
|
||||
setServiceLabels(
|
||||
labels.map((l) => ({ label: labelMappings[l] ?? l, value: l })),
|
||||
);
|
||||
}
|
||||
}, [loadingServiceLabelValues, data]);
|
||||
return options.map(({ value, label }) => (
|
||||
<Option key={value} value={value} className="text-sm+ font-medium">
|
||||
{label}
|
||||
</Option>
|
||||
));
|
||||
}, [data]);
|
||||
|
||||
const form = useForm<LogsFilterFormValues>({
|
||||
defaultValues: {
|
||||
@@ -119,30 +114,10 @@ export default function LogsHeader({
|
||||
className="grid w-full grid-flow-row items-center gap-2 md:w-[initial] md:grid-flow-col md:gap-3 lg:justify-end"
|
||||
>
|
||||
<Box className="flex flex-row space-x-2">
|
||||
<ControlledSelect
|
||||
{...register('service')}
|
||||
className="w-full min-w-fit text-sm font-normal"
|
||||
placeholder="All Services"
|
||||
aria-label="Select service"
|
||||
hideEmptyHelperText
|
||||
slotProps={{
|
||||
root: {
|
||||
className: 'min-h-[initial] h-10 leading-[initial]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{[{ label: 'All services', value: '' }, ...serviceLabels].map(
|
||||
({ value, label }) => (
|
||||
<Option
|
||||
key={value}
|
||||
value={value}
|
||||
className="text-sm+ font-medium"
|
||||
>
|
||||
{label}
|
||||
</Option>
|
||||
),
|
||||
)}
|
||||
</ControlledSelect>
|
||||
<LogsServiceFilter
|
||||
register={register}
|
||||
serviceOptions={serviceOptions}
|
||||
/>
|
||||
<div className="w-full min-w-fit">
|
||||
<LogsRangeSelector onSubmitFilterValues={onSubmitFilterValues} />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ControlledSelect } from '@/components/form/ControlledSelect';
|
||||
import { memo } from 'react';
|
||||
import type { UseFormRegister } from 'react-hook-form';
|
||||
import type { LogsFilterFormValues } from './LogsHeader';
|
||||
|
||||
interface LogsServiceFilterProps {
|
||||
register: UseFormRegister<LogsFilterFormValues>;
|
||||
serviceOptions: JSX.Element[];
|
||||
}
|
||||
const LogsServiceFilter = memo(
|
||||
({ register, serviceOptions }: LogsServiceFilterProps) => (
|
||||
<ControlledSelect
|
||||
{...register('service')}
|
||||
className="w-full min-w-fit text-sm font-normal"
|
||||
placeholder="All Services"
|
||||
aria-label="Select service"
|
||||
hideEmptyHelperText
|
||||
slotProps={{
|
||||
root: {
|
||||
className: 'min-h-[initial] h-10 leading-[initial]',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{serviceOptions}
|
||||
</ControlledSelect>
|
||||
),
|
||||
);
|
||||
|
||||
export default LogsServiceFilter;
|
||||
@@ -10,34 +10,21 @@ export enum AvailableLogsService {
|
||||
STORAGE = 'hasura-storage',
|
||||
HASURA = 'hasura',
|
||||
FUNCTIONS = 'functions',
|
||||
GRAFANA = 'grafana',
|
||||
JOB_BACKUP = 'job-backup',
|
||||
AI = 'ai',
|
||||
}
|
||||
|
||||
export const LOGS_AVAILABLE_SERVICES: {
|
||||
label: string;
|
||||
value: AvailableLogsService;
|
||||
}[] = [
|
||||
{
|
||||
label: 'All Services',
|
||||
value: AvailableLogsService.ALL,
|
||||
},
|
||||
{
|
||||
label: 'Postgres',
|
||||
value: AvailableLogsService.POSTGRES,
|
||||
},
|
||||
{
|
||||
label: 'Auth',
|
||||
value: AvailableLogsService.AUTH,
|
||||
},
|
||||
{
|
||||
label: 'Storage',
|
||||
value: AvailableLogsService.STORAGE,
|
||||
},
|
||||
{
|
||||
label: 'Hasura',
|
||||
value: AvailableLogsService.HASURA,
|
||||
},
|
||||
{
|
||||
label: 'Functions',
|
||||
value: AvailableLogsService.FUNCTIONS,
|
||||
},
|
||||
];
|
||||
export const LOGS_SERVICE_TO_LABEL: Required<
|
||||
Record<AvailableLogsService, string>
|
||||
> = {
|
||||
[AvailableLogsService.ALL]: 'All Services',
|
||||
[AvailableLogsService.POSTGRES]: 'Postgres',
|
||||
[AvailableLogsService.AUTH]: 'Auth',
|
||||
[AvailableLogsService.STORAGE]: 'Storage',
|
||||
[AvailableLogsService.HASURA]: 'Hasura',
|
||||
[AvailableLogsService.FUNCTIONS]: 'Functions',
|
||||
[AvailableLogsService.GRAFANA]: 'Grafana',
|
||||
[AvailableLogsService.JOB_BACKUP]: 'Backup Jobs',
|
||||
[AvailableLogsService.AI]: 'AI',
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('next/router', () => ({
|
||||
|
||||
const server = setupServer(
|
||||
tokenQuery,
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
rest.get('https://local.graphql.local.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
),
|
||||
);
|
||||
@@ -49,7 +49,7 @@ beforeAll(() => {
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers(
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
rest.get('https://local.graphql.local.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
),
|
||||
);
|
||||
@@ -63,33 +63,36 @@ afterAll(() => {
|
||||
|
||||
test('should render an empty state when GitHub is not connected', async () => {
|
||||
server.use(
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
apps: [{ ...mockApplication, githubRepository: null }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
organizations: [{ ...mockOrganization }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
apps: [{ ...mockApplication, githubRepository: null }],
|
||||
data: {
|
||||
deployments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
organizations: [{ ...mockOrganization }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
@@ -102,31 +105,34 @@ test('should render an empty state when GitHub is not connected', async () => {
|
||||
|
||||
test('should render an empty state when GitHub is connected, but there are no deployments', async () => {
|
||||
server.use(
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
apps: [{ ...mockApplication }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
apps: [{ ...mockApplication }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
}),
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
@@ -147,52 +153,55 @@ test('should render an empty state when GitHub is connected, but there are no de
|
||||
test('should render a list of deployments', async () => {
|
||||
server.use(
|
||||
tokenQuery,
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
}
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
}
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
apps: [{ ...mockApplication }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
apps: [{ ...mockApplication }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
@@ -216,20 +225,62 @@ test('should render a list of deployments', async () => {
|
||||
test('should disable redeployments if a deployment is already in progress', async () => {
|
||||
server.use(
|
||||
tokenQuery,
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '2',
|
||||
commitSHA: 'abc234',
|
||||
deploymentStartedAt: '2021-08-02T00:00:00.000Z',
|
||||
deploymentEndedAt: null,
|
||||
deploymentStatus: 'PENDING',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
apps: [{ ...mockApplication }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '2',
|
||||
commitSHA: 'abc234',
|
||||
deploymentStartedAt: '2021-08-02T00:00:00.000Z',
|
||||
deploymentEndedAt: null,
|
||||
deploymentStatus: 'PENDING',
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
@@ -238,47 +289,8 @@ test('should disable redeployments if a deployment is already in progress', asyn
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
apps: [{ ...mockApplication }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'getOrganization') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
organizations: [{ ...mockOrganization }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/v3/button';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import UpgradeProjectDialog from '@/features/orgs/projects/overview/components/OverviewTopBar/UpgradeProjectDialog';
|
||||
import { formatDistanceToNowStrict, parseISO } from 'date-fns';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
@@ -15,6 +16,8 @@ export default function OverviewTopBar() {
|
||||
const { project } = useProject();
|
||||
const { maintenanceActive } = useUI();
|
||||
|
||||
const isFreeProject = !!org?.plan.isFree;
|
||||
|
||||
if (!isPlatform) {
|
||||
return (
|
||||
<div className="flex flex-row place-content-between items-center py-5">
|
||||
@@ -87,22 +90,24 @@ export default function OverviewTopBar() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
<div className="flex content-center gap-4">
|
||||
{isFreeProject && <UpgradeProjectDialog />}
|
||||
<Link
|
||||
href={`/orgs/${org?.slug}/projects/${project?.subdomain}/settings`}
|
||||
passHref
|
||||
legacyBehavior
|
||||
>
|
||||
Settings
|
||||
<CogIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
color="secondary"
|
||||
disabled={maintenanceActive}
|
||||
>
|
||||
Settings
|
||||
<CogIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
function UpgradeProjectDialog() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleDialogOpen = useCallback(() => setOpen(true), []);
|
||||
return (
|
||||
<>
|
||||
<OpenTransferDialogButton
|
||||
buttonText="Upgrade project"
|
||||
onClick={handleDialogOpen}
|
||||
/>
|
||||
<TransferProjectDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default UpgradeProjectDialog;
|
||||
@@ -44,7 +44,7 @@ export default function ResourcesConfirmationDialog({
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: formValues.database?.replicas,
|
||||
replicas: 1,
|
||||
vcpu: formValues.database?.vcpu,
|
||||
memory: formValues.database?.memory,
|
||||
},
|
||||
@@ -128,7 +128,6 @@ export default function ResourcesConfirmationDialog({
|
||||
included in the {proPlan.name} plan.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Box className="grid grid-flow-row gap-4">
|
||||
{/* <Box className="grid justify-between grid-flow-col gap-2">
|
||||
<Text className="font-medium">{proPlan.name} Plan</Text>
|
||||
@@ -227,7 +226,6 @@ export default function ResourcesConfirmationDialog({
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box className="grid grid-flow-row gap-2">
|
||||
<Button
|
||||
color={totalBillableVCPU > 0 ? 'primary' : 'error'}
|
||||
|
||||
@@ -40,9 +40,16 @@ function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { compute, replicas, autoscaler, ...rest } =
|
||||
if (service === 'postgres') {
|
||||
const { compute, ...rest } = data?.config?.[service]?.resources || {};
|
||||
return {
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
const { compute, autoscaler, replicas, ...rest } =
|
||||
data?.config?.[service]?.resources || {};
|
||||
|
||||
return {
|
||||
replicas,
|
||||
vcpu: compute?.cpu || 0,
|
||||
@@ -103,11 +110,8 @@ export default function ResourcesForm() {
|
||||
totalAvailableVCPU: totalInitialVCPU || 2000,
|
||||
totalAvailableMemory: totalInitialMemory || 4096,
|
||||
database: {
|
||||
replicas: initialDatabaseResources.replicas || 1,
|
||||
vcpu: initialDatabaseResources.vcpu || 1000,
|
||||
memory: initialDatabaseResources.memory || 2048,
|
||||
autoscale: !!initialDatabaseResources.autoscale || false,
|
||||
maxReplicas: initialDatabaseResources.autoscale?.maxReplicas || 10,
|
||||
},
|
||||
hasura: {
|
||||
replicas: initialHasuraResources.replicas || 1,
|
||||
@@ -160,7 +164,7 @@ export default function ResourcesForm() {
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: initialDatabaseResources.replicas,
|
||||
replicas: 1,
|
||||
vcpu: initialDatabaseResources.vcpu,
|
||||
},
|
||||
{
|
||||
@@ -207,12 +211,6 @@ export default function ResourcesForm() {
|
||||
cpu: sanitizedValues.database.vcpu,
|
||||
memory: sanitizedValues.database.memory,
|
||||
},
|
||||
replicas: sanitizedValues.database.replicas,
|
||||
autoscaler: sanitizedValues.database.autoscale
|
||||
? {
|
||||
maxReplicas: sanitizedValues.database.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
...sanitizedInitialDatabaseResources.rest,
|
||||
},
|
||||
},
|
||||
@@ -268,8 +266,6 @@ export default function ResourcesForm() {
|
||||
postgres: {
|
||||
resources: {
|
||||
compute: null,
|
||||
replicas: null,
|
||||
autoscaler: null,
|
||||
...sanitizedInitialDatabaseResources.rest,
|
||||
},
|
||||
},
|
||||
@@ -341,9 +337,6 @@ export default function ResourcesForm() {
|
||||
totalAvailableVCPU: 2000,
|
||||
totalAvailableMemory: 4096,
|
||||
database: {
|
||||
replicas: 1,
|
||||
maxReplicas: 1,
|
||||
autoscale: false,
|
||||
vcpu: 1000,
|
||||
memory: 2048,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,7 @@ export default function ResourcesFormFooter() {
|
||||
|
||||
const billableResources = calculateBillableResources(
|
||||
{
|
||||
replicas: database?.replicas,
|
||||
replicas: 1, // database replica is always one
|
||||
vcpu: database?.vcpu,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -43,10 +43,6 @@ fragment ServiceResources on ConfigConfig {
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
autoscaler {
|
||||
maxReplicas
|
||||
}
|
||||
}
|
||||
}
|
||||
storage {
|
||||
|
||||
@@ -76,7 +76,7 @@ export const MAX_SERVICE_MEMORY =
|
||||
RESOURCE_VCPU_MEMORY_RATIO *
|
||||
RESOURCE_MEMORY_MULTIPLIER;
|
||||
|
||||
const serviceValidationSchema = Yup.object({
|
||||
const genericServiceValidationSchema = Yup.object({
|
||||
replicas: Yup.number()
|
||||
.label('Replicas')
|
||||
.required()
|
||||
@@ -115,6 +115,18 @@ const serviceValidationSchema = Yup.object({
|
||||
),
|
||||
});
|
||||
|
||||
const postgresServiceValidationSchema = Yup.object({
|
||||
vcpu: Yup.number()
|
||||
.label('vCPUs')
|
||||
.required()
|
||||
.min(MIN_SERVICE_VCPU)
|
||||
.max(MAX_SERVICE_VCPU),
|
||||
memory: Yup.number()
|
||||
.required()
|
||||
.min(MIN_SERVICE_MEMORY)
|
||||
.max(MAX_SERVICE_MEMORY)
|
||||
});
|
||||
|
||||
export const resourceSettingsValidationSchema = Yup.object({
|
||||
enabled: Yup.boolean(),
|
||||
totalAvailableVCPU: Yup.number()
|
||||
@@ -147,10 +159,10 @@ export const resourceSettingsValidationSchema = Yup.object({
|
||||
parent.storage.memory ===
|
||||
totalAvailableMemory,
|
||||
),
|
||||
database: serviceValidationSchema.required(),
|
||||
hasura: serviceValidationSchema.required(),
|
||||
auth: serviceValidationSchema.required(),
|
||||
storage: serviceValidationSchema.required(),
|
||||
database: postgresServiceValidationSchema.required(),
|
||||
hasura: genericServiceValidationSchema.required(),
|
||||
auth: genericServiceValidationSchema.required(),
|
||||
storage: genericServiceValidationSchema.required(),
|
||||
});
|
||||
|
||||
export type ResourceSettingsFormValues = Yup.InferType<
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useCurrentWorkspaceAndProject } from '@/features/projects/common/hooks/useCurrentWorkspaceAndProject';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
/**
|
||||
* Redirects to 404 page if either currentWorkspace/currentProject resolves to undefined.
|
||||
* Redirects to 404 page if either currentWorkspace/currentProject resolves to undefined
|
||||
* or if the current pathname is not a valid organization/project.
|
||||
* Not applicable if running dashboard with local Nhost backend.
|
||||
*/
|
||||
export default function useNotFoundRedirect() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
query: { orgSlug, workspaceSlug, appSubdomain, updating, appSlug },
|
||||
query: {
|
||||
orgSlug: urlOrgSlug,
|
||||
workspaceSlug: urlWorkspaceSlug,
|
||||
appSubdomain: urlAppSubdomain,
|
||||
updating,
|
||||
appSlug: urlAppSlug,
|
||||
},
|
||||
isReady,
|
||||
} = router;
|
||||
|
||||
const { project, loading: projectLoading } = useProject();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { org, loading: orgLoading } = useCurrentOrg();
|
||||
|
||||
const { subdomain: projectSubdomain } = project || {};
|
||||
const { slug: currentOrgSlug } = org || {};
|
||||
|
||||
const { currentProject, currentWorkspace, loading } =
|
||||
useCurrentWorkspaceAndProject();
|
||||
|
||||
@@ -23,6 +41,10 @@ export default function useNotFoundRedirect() {
|
||||
!isReady ||
|
||||
// If the current workspace and project are not loaded, we don't want to redirect to 404
|
||||
loading ||
|
||||
// If the project is loading, we don't want to redirect to 404
|
||||
projectLoading ||
|
||||
// If the org is loading, we don't want to redirect to 404
|
||||
orgLoading ||
|
||||
// If we're already on the 404 page, we don't want to redirect to 404
|
||||
router.pathname === '/404' ||
|
||||
router.pathname === '/' ||
|
||||
@@ -31,12 +53,16 @@ export default function useNotFoundRedirect() {
|
||||
router.pathname === '/run-one-click-install' ||
|
||||
router.pathname.includes('/orgs/_') ||
|
||||
router.pathname.includes('/orgs/_/projects/_') ||
|
||||
orgSlug ||
|
||||
(orgSlug && appSubdomain) ||
|
||||
(!isPlatform && router.pathname.includes('/orgs/local')) ||
|
||||
(!isPlatform && router.pathname.includes('/orgs/[orgSlug]/projects')) ||
|
||||
// If we are on a valid org and project, we don't want to redirect to 404
|
||||
(urlOrgSlug && currentOrgSlug && urlAppSubdomain && projectSubdomain) ||
|
||||
// If we are on a valid org and no project is selected, we don't want to redirect to 404
|
||||
(urlOrgSlug && currentOrgSlug && !urlAppSubdomain && !projectSubdomain) ||
|
||||
// If we are on a valid workspace and project, we don't want to redirect to 404
|
||||
(workspaceSlug && currentWorkspace && appSlug && currentProject) ||
|
||||
(urlWorkspaceSlug && currentWorkspace && urlAppSlug && currentProject) ||
|
||||
// If we are on a valid workspace and no project is selected, we don't want to redirect to 404
|
||||
(workspaceSlug && currentWorkspace && !appSlug && !currentProject)
|
||||
(urlWorkspaceSlug && currentWorkspace && !urlAppSlug && !currentProject)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -47,11 +73,16 @@ export default function useNotFoundRedirect() {
|
||||
currentWorkspace,
|
||||
isReady,
|
||||
loading,
|
||||
appSubdomain,
|
||||
appSlug,
|
||||
urlAppSubdomain,
|
||||
urlAppSlug,
|
||||
router,
|
||||
updating,
|
||||
workspaceSlug,
|
||||
orgSlug,
|
||||
projectLoading,
|
||||
orgLoading,
|
||||
currentOrgSlug,
|
||||
projectSubdomain,
|
||||
urlWorkspaceSlug,
|
||||
urlOrgSlug,
|
||||
isPlatform,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ vi.mock('next/router', () => ({
|
||||
|
||||
const server = setupServer(
|
||||
tokenQuery,
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
rest.get('https://local.graphql.local.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
),
|
||||
);
|
||||
@@ -49,7 +49,7 @@ beforeAll(() => {
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers(
|
||||
rest.get('https://local.graphql.nhost.run/v1', (_req, res, ctx) =>
|
||||
rest.get('https://local.graphql.local.nhost.run/v1', (_req, res, ctx) =>
|
||||
res(ctx.status(200)),
|
||||
),
|
||||
);
|
||||
@@ -63,33 +63,36 @@ afterAll(() => {
|
||||
|
||||
test('should render an empty state when GitHub is not connected', async () => {
|
||||
server.use(
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [
|
||||
{
|
||||
...mockWorkspace,
|
||||
projects: [{ ...mockApplication, githubRepository: null }],
|
||||
},
|
||||
],
|
||||
projects: [{ ...mockApplication, githubRepository: null }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [
|
||||
{
|
||||
...mockWorkspace,
|
||||
projects: [{ ...mockApplication, githubRepository: null }],
|
||||
},
|
||||
],
|
||||
projects: [{ ...mockApplication, githubRepository: null }],
|
||||
deployments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
@@ -102,22 +105,25 @@ test('should render an empty state when GitHub is not connected', async () => {
|
||||
|
||||
test('should render an empty state when GitHub is connected, but there are no deployments', async () => {
|
||||
server.use(
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
}),
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
@@ -138,43 +144,46 @@ test('should render an empty state when GitHub is connected, but there are no de
|
||||
test('should render a list of deployments', async () => {
|
||||
server.use(
|
||||
tokenQuery,
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (_req, res, ctx) => {
|
||||
const { operationName } = await _req.json();
|
||||
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
}
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(ctx.json({ data: { deployments: [] } }));
|
||||
}
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
deployments: [
|
||||
{
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
@@ -198,20 +207,53 @@ test('should render a list of deployments', async () => {
|
||||
test('should disable redeployments if a deployment is already in progress', async () => {
|
||||
server.use(
|
||||
tokenQuery,
|
||||
rest.post('https://local.graphql.nhost.run/v1', async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
rest.post(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
async (req, res, ctx) => {
|
||||
const { operationName } = await req.json();
|
||||
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '2',
|
||||
commitSHA: 'abc234',
|
||||
deploymentStartedAt: '2021-08-02T00:00:00.000Z',
|
||||
deploymentEndedAt: null,
|
||||
deploymentStatus: 'PENDING',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'ScheduledOrPendingDeploymentsSub') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '2',
|
||||
commitSHA: 'abc234',
|
||||
deploymentStartedAt: '2021-08-02T00:00:00.000Z',
|
||||
deploymentEndedAt: null,
|
||||
deploymentStatus: 'PENDING',
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
@@ -220,38 +262,8 @@ test('should disable redeployments if a deployment is already in progress', asyn
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (operationName === 'GetWorkspaceAndProject') {
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
workspaces: [mockWorkspace],
|
||||
projects: [mockApplication],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return res(
|
||||
ctx.json({
|
||||
data: {
|
||||
deployments: [
|
||||
{
|
||||
id: '1',
|
||||
commitSHA: 'abc123',
|
||||
deploymentStartedAt: '2021-08-01T00:00:00.000Z',
|
||||
deploymentEndedAt: '2021-08-01T00:05:00.000Z',
|
||||
deploymentStatus: 'DEPLOYED',
|
||||
commitUserName: 'test.user',
|
||||
commitUserAvatarUrl: 'http://images.example.com/avatar.png',
|
||||
commitMessage: 'Test commit message',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
render(<OverviewDeployments />);
|
||||
|
||||
@@ -37,14 +37,24 @@ function getInitialServiceResources(
|
||||
data: GetResourcesQuery,
|
||||
service: Exclude<keyof GetResourcesQuery['config'], '__typename'>,
|
||||
) {
|
||||
const { compute, replicas, autoscaler } =
|
||||
if (service === 'postgres') {
|
||||
const { compute, ...rest } = data?.config?.[service]?.resources || {};
|
||||
return {
|
||||
replicas: 1,
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
autoscale: null,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
const { compute, autoscaler, replicas, ...rest } =
|
||||
data?.config?.[service]?.resources || {};
|
||||
|
||||
return {
|
||||
replicas,
|
||||
vcpu: compute?.cpu || 0,
|
||||
memory: compute?.memory || 0,
|
||||
autoscale: autoscaler || null,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -190,12 +200,6 @@ export default function ResourcesForm() {
|
||||
cpu: formValues.database.vcpu,
|
||||
memory: formValues.database.memory,
|
||||
},
|
||||
replicas: formValues.database.replicas,
|
||||
autoscaler: formValues.database.autoscale
|
||||
? {
|
||||
maxReplicas: formValues.database.maxReplicas,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
|
||||
@@ -23,7 +23,11 @@ fragment RemoteAppGetUsers on users {
|
||||
disabled
|
||||
}
|
||||
|
||||
query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||
query remoteAppGetUsersAndAuthRoles(
|
||||
$where: users_bool_exp!
|
||||
$limit: Int!
|
||||
$offset: Int!
|
||||
) {
|
||||
users(
|
||||
where: $where
|
||||
limit: $limit
|
||||
@@ -42,6 +46,9 @@ query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||
count
|
||||
}
|
||||
}
|
||||
authRoles {
|
||||
role
|
||||
}
|
||||
}
|
||||
|
||||
query remoteAppGetUsersCustom(
|
||||
|
||||
6
dashboard/src/lib/segment.ts
Normal file
6
dashboard/src/lib/segment.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { AnalyticsBrowser } from "@segment/analytics-next";
|
||||
|
||||
export const analytics = AnalyticsBrowser.load({
|
||||
cdnURL: process.env.NEXT_PUBLIC_SEGMENT_CDN_URL!,
|
||||
writeKey: process.env.NEXT_PUBLIC_ANALYTICS_WRITE_KEY!,
|
||||
});
|
||||
35
dashboard/src/lib/utils.test.ts
Normal file
35
dashboard/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { isEmptyValue } from './utils';
|
||||
|
||||
test('returns true when the value is undefined or "undefined"', () => {
|
||||
expect(isEmptyValue(undefined)).toBe(true)
|
||||
expect(isEmptyValue('undefined')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when the value is null or "null"', () => {
|
||||
expect(isEmptyValue(null)).toBe(true)
|
||||
expect(isEmptyValue('null')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when the value is an empty string', () => {
|
||||
expect(isEmptyValue('')).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when the value is "NaN" or not a number', () => {
|
||||
expect(isEmptyValue("NaN")).toBe(true)
|
||||
expect(isEmptyValue(NaN)).toBe(true)
|
||||
})
|
||||
|
||||
test('returns true when the value is an empty object or array', () => {
|
||||
expect(isEmptyValue({})).toBe(true)
|
||||
expect(isEmptyValue([])).toBe(true)
|
||||
})
|
||||
|
||||
test('returns false when the value is has at least one property or the array has at least on item in it', () => {
|
||||
expect(isEmptyValue({foo: 'Bar'})).toBe(false)
|
||||
expect(isEmptyValue(['foo', 'bar'])).toBe(false)
|
||||
})
|
||||
|
||||
test('returns false when the value is either a number or string', () => {
|
||||
expect(isEmptyValue(1234)).toBe(false)
|
||||
expect(isEmptyValue('Hello there')).toBe(false)
|
||||
})
|
||||
@@ -4,3 +4,20 @@ import { twMerge } from "tailwind-merge"
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function isEmptyValue<T>(value: T) {
|
||||
return (
|
||||
value === undefined ||
|
||||
value === 'undefined' ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
value === 'null' ||
|
||||
value === 'NaN' ||
|
||||
(typeof value === 'number' && Number.isNaN(value)) ||
|
||||
(typeof value === 'object' && Object.keys(value).length === 0)
|
||||
)
|
||||
}
|
||||
|
||||
export function isNotEmptyValue<T>(value: T): value is Exclude<T, undefined | null> {
|
||||
return !isEmptyValue(value)
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ import { CreateUserForm } from '@/features/authentication/users/components/Creat
|
||||
import { UsersBody } from '@/features/authentication/users/components/UsersBody';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { useRemoteApplicationGQLClient } from '@/hooks/useRemoteApplicationGQLClient';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Router, { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type RemoteAppUser = Exclude<
|
||||
RemoteAppGetUsersQuery['users'][0],
|
||||
RemoteAppGetUsersAndAuthRolesQuery['users'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function UsersPage() {
|
||||
data: dataRemoteAppUsers,
|
||||
refetch: refetchProjectUsers,
|
||||
loading: loadingRemoteAppUsersQuery,
|
||||
} = useRemoteAppGetUsersQuery({
|
||||
} = useRemoteAppGetUsersAndAuthRolesQuery({
|
||||
variables: remoteAppGetUserVariables,
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Analytics from '@/components/analytics/analytics';
|
||||
import { DialogProvider } from '@/components/common/DialogProvider';
|
||||
import { UIProvider } from '@/components/common/UIProvider';
|
||||
import { TreeNavStateProvider } from '@/components/layout/MainNav/TreeNavStateContext';
|
||||
@@ -28,14 +29,12 @@ import '@fontsource/roboto-mono/400.css';
|
||||
import '@fontsource/roboto-mono/500.css';
|
||||
import { NhostProvider } from '@nhost/nextjs';
|
||||
import { NhostApolloProvider } from '@nhost/react-apollo';
|
||||
import * as snippet from '@segment/snippet';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import type { NextPage } from 'next';
|
||||
import { PagesProgressBar as ProgressBar } from 'next-nprogress-bar';
|
||||
import { DefaultSeo } from 'next-seo';
|
||||
import type { AppProps } from 'next/app';
|
||||
import { useRouter } from 'next/router';
|
||||
import Script from 'next/script';
|
||||
import type { ReactElement } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
@@ -62,20 +61,6 @@ function MyApp({
|
||||
const isPlatform = useIsPlatform();
|
||||
const router = useRouter();
|
||||
|
||||
// segment snippet
|
||||
function renderSnippet() {
|
||||
const opts = {
|
||||
apiKey: process.env.NEXT_PUBLIC_ANALYTICS_WRITE_KEY,
|
||||
page: true,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return snippet.max(opts);
|
||||
}
|
||||
|
||||
return snippet.min(opts);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// track page changes
|
||||
const handleRouteChange = () => {
|
||||
@@ -103,12 +88,7 @@ function MyApp({
|
||||
<UIProvider>
|
||||
<Toaster position="bottom-center" />
|
||||
|
||||
{isPlatform && (
|
||||
<Script
|
||||
id="segment"
|
||||
dangerouslySetInnerHTML={{ __html: renderSnippet() }}
|
||||
/>
|
||||
)}
|
||||
{isPlatform && <Analytics />}
|
||||
|
||||
<ThemeProvider
|
||||
colorPreferenceStorageKey={COLOR_PREFERENCE_STORAGE_KEY}
|
||||
|
||||
@@ -30,7 +30,7 @@ interface LogsFilters {
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const { project } = useProject();
|
||||
const { project, loading: loadingProject } = useProject();
|
||||
|
||||
// create a client that sends http requests to Hasura but websocket requests to Bragi
|
||||
const clientWithSplit = useRemoteApplicationGQLClientWithSubscriptions();
|
||||
@@ -43,13 +43,19 @@ export default function LogsPage() {
|
||||
service: AvailableLogsService.ALL,
|
||||
});
|
||||
|
||||
const { data, error, subscribeToMore, client, loading, refetch } =
|
||||
useGetProjectLogsQuery({
|
||||
variables: { appID: project?.id, ...filters },
|
||||
client: clientWithSplit,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
error,
|
||||
subscribeToMore,
|
||||
client,
|
||||
loading: loadingLogs,
|
||||
} = useGetProjectLogsQuery({
|
||||
variables: { appID: project?.id, ...filters },
|
||||
client: clientWithSplit,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
skip: !project,
|
||||
});
|
||||
|
||||
const subscribeToMoreLogs = useCallback(
|
||||
() =>
|
||||
@@ -102,15 +108,19 @@ export default function LogsPage() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!project || loadingProject) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filters.to && subscriptionReturn.current !== null) {
|
||||
subscriptionReturn.current();
|
||||
subscriptionReturn.current = null;
|
||||
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
|
||||
if (filters.to) {
|
||||
return () => {};
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscriptionReturn.current) {
|
||||
@@ -120,18 +130,17 @@ export default function LogsPage() {
|
||||
|
||||
// This will open the websocket connection and it will return a function to close it.
|
||||
subscriptionReturn.current = subscribeToMoreLogs();
|
||||
|
||||
return () => {};
|
||||
}, [filters, subscribeToMoreLogs, client]);
|
||||
}, [filters, subscribeToMoreLogs, client, project, loadingProject]);
|
||||
|
||||
const onSubmitFilterValues = useCallback(
|
||||
async (values: LogsFilterFormValues) => {
|
||||
setFilters({ ...(values as LogsFilters) });
|
||||
await refetch();
|
||||
},
|
||||
[setFilters, refetch],
|
||||
[setFilters],
|
||||
);
|
||||
|
||||
const loading = loadingProject || loadingLogs;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col">
|
||||
<RetryableErrorBoundary>
|
||||
|
||||
@@ -13,15 +13,16 @@ import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteAp
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { CreateUserForm } from '@/features/orgs/projects/authentication/users/components/CreateUserForm';
|
||||
import { UsersBody } from '@/features/orgs/projects/authentication/users/components/UsersBody';
|
||||
import type { RemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersQuery } from '@/utils/__generated__/graphql';
|
||||
import { getUserRoles } from '@/features/projects/roles/settings/utils/getUserRoles';
|
||||
import type { RemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import { useRemoteAppGetUsersAndAuthRolesQuery } from '@/utils/__generated__/graphql';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Router, { useRouter } from 'next/router';
|
||||
import type { ChangeEvent, ReactElement } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
export type RemoteAppUser = Exclude<
|
||||
RemoteAppGetUsersQuery['users'][0],
|
||||
RemoteAppGetUsersAndAuthRolesQuery['users'][0],
|
||||
'__typename'
|
||||
>;
|
||||
|
||||
@@ -72,14 +73,13 @@ export default function UsersPage() {
|
||||
);
|
||||
|
||||
const {
|
||||
data: dataRemoteAppUsers,
|
||||
data: dataRemoteAppUsersAndAuthRoles,
|
||||
refetch: refetchProjectUsers,
|
||||
loading: loadingRemoteAppUsersQuery,
|
||||
} = useRemoteAppGetUsersQuery({
|
||||
} = useRemoteAppGetUsersAndAuthRolesQuery({
|
||||
variables: remoteAppGetUserVariables,
|
||||
client: remoteProjectGQLClient,
|
||||
});
|
||||
|
||||
/**
|
||||
* This function will remove query params from the URL.
|
||||
*
|
||||
@@ -175,13 +175,13 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
const userCount = searchString
|
||||
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count
|
||||
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count;
|
||||
? dataRemoteAppUsersAndAuthRoles?.filteredUsersAggreggate.aggregate.count
|
||||
: dataRemoteAppUsersAndAuthRoles?.usersAggregate?.aggregate?.count;
|
||||
|
||||
setNrOfPages(Math.ceil(userCount / limit.current));
|
||||
}, [
|
||||
dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count,
|
||||
dataRemoteAppUsers?.usersAggregate.aggregate.count,
|
||||
dataRemoteAppUsersAndAuthRoles?.filteredUsersAggreggate.aggregate.count,
|
||||
dataRemoteAppUsersAndAuthRoles?.usersAggregate.aggregate.count,
|
||||
loadingRemoteAppUsersQuery,
|
||||
searchString,
|
||||
]);
|
||||
@@ -208,17 +208,26 @@ export default function UsersPage() {
|
||||
}
|
||||
|
||||
const users = useMemo(
|
||||
() => dataRemoteAppUsers?.users.map((user) => user) ?? [],
|
||||
[dataRemoteAppUsers],
|
||||
() => dataRemoteAppUsersAndAuthRoles?.users.map((user) => user) ?? [],
|
||||
[dataRemoteAppUsersAndAuthRoles],
|
||||
);
|
||||
|
||||
const usersCount = useMemo(
|
||||
() => dataRemoteAppUsers?.usersAggregate?.aggregate?.count ?? -1,
|
||||
[dataRemoteAppUsers],
|
||||
() =>
|
||||
dataRemoteAppUsersAndAuthRoles?.usersAggregate?.aggregate?.count ?? -1,
|
||||
[dataRemoteAppUsersAndAuthRoles],
|
||||
);
|
||||
|
||||
const authRoles = (dataRemoteAppUsersAndAuthRoles?.authRoles || []).map(
|
||||
(authRole) => authRole.role,
|
||||
);
|
||||
const allAvailableProjectRoles = useMemo(
|
||||
() => getUserRoles(authRoles),
|
||||
[authRoles],
|
||||
);
|
||||
|
||||
const thereAreUsers =
|
||||
dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ||
|
||||
dataRemoteAppUsersAndAuthRoles?.filteredUsersAggreggate.aggregate.count ||
|
||||
usersCount <= 0;
|
||||
|
||||
if (loadingRemoteAppUsersQuery) {
|
||||
@@ -315,8 +324,8 @@ export default function UsersPage() {
|
||||
OAuth Providers
|
||||
</Text>
|
||||
</Box>
|
||||
{dataRemoteAppUsers?.filteredUsersAggreggate.aggregate.count ===
|
||||
0 &&
|
||||
{dataRemoteAppUsersAndAuthRoles?.filteredUsersAggreggate.aggregate
|
||||
.count === 0 &&
|
||||
usersCount !== 0 && (
|
||||
<Box className="flex flex-col items-center justify-center space-y-5 border-x border-b px-48 py-12">
|
||||
<UserIcon
|
||||
@@ -336,22 +345,27 @@ export default function UsersPage() {
|
||||
)}
|
||||
{thereAreUsers && (
|
||||
<div className="grid grid-flow-row gap-4">
|
||||
<UsersBody users={users} onSubmit={refetchProjectUsers} />
|
||||
<UsersBody
|
||||
users={users}
|
||||
onSubmit={refetchProjectUsers}
|
||||
allAvailableProjectRoles={allAvailableProjectRoles}
|
||||
/>
|
||||
<Pagination
|
||||
className="px-2"
|
||||
totalNrOfPages={nrOfPages}
|
||||
currentPageNumber={currentPage}
|
||||
totalNrOfElements={
|
||||
searchString
|
||||
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate
|
||||
.count
|
||||
: dataRemoteAppUsers?.usersAggregate?.aggregate?.count
|
||||
? dataRemoteAppUsersAndAuthRoles?.filteredUsersAggreggate
|
||||
.aggregate.count
|
||||
: dataRemoteAppUsersAndAuthRoles?.usersAggregate
|
||||
?.aggregate?.count
|
||||
}
|
||||
itemsLabel="users"
|
||||
elementsPerPage={
|
||||
searchString
|
||||
? dataRemoteAppUsers?.filteredUsersAggreggate.aggregate
|
||||
.count
|
||||
? dataRemoteAppUsersAndAuthRoles?.filteredUsersAggreggate
|
||||
.aggregate.count
|
||||
: limit.current
|
||||
}
|
||||
onPrevPageClick={async () => {
|
||||
|
||||
@@ -1,31 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { BaseLayout } from '@/components/layout/BaseLayout';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { FinishOrgCreationProcess } from '@/features/orgs/components/common/FinishOrgCreationProcess';
|
||||
import { useFinishOrgCreation } from '@/features/orgs/hooks/useFinishOrgCreation';
|
||||
import { useIsPlatform } from '@/features/projects/common/hooks/useIsPlatform';
|
||||
import {
|
||||
CheckoutStatus,
|
||||
useGetOrganizationByIdLazyQuery,
|
||||
usePostOrganizationRequestMutation,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { execPromiseWithErrorToast } from '@/utils/execPromiseWithErrorToast';
|
||||
import type { PostOrganizationRequestMutation } from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export default function PostCheckout() {
|
||||
const router = useRouter();
|
||||
const { session_id } = router.query;
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [postOrganizationRequest] = usePostOrganizationRequestMutation();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
|
||||
const [fetchOrg] = useGetOrganizationByIdLazyQuery();
|
||||
const [postOrganizationRequestStatus, setPostOrganizationRequestStatus] =
|
||||
useState<CheckoutStatus | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlatform || isLoading || isAuthenticated) {
|
||||
return;
|
||||
@@ -34,99 +23,30 @@ export default function PostCheckout() {
|
||||
router.push('/signin');
|
||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (session_id && isAuthenticated) {
|
||||
setLoading(true);
|
||||
const onCompleted = useCallback(
|
||||
(
|
||||
data: PostOrganizationRequestMutation['billingPostOrganizationRequest'],
|
||||
) => {
|
||||
const { Slug } = data;
|
||||
router.push(`/orgs/${Slug}/projects`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const {
|
||||
data: { billingPostOrganizationRequest },
|
||||
} = await postOrganizationRequest({
|
||||
variables: {
|
||||
sessionID: session_id as string,
|
||||
},
|
||||
});
|
||||
|
||||
const { Status, Slug } = billingPostOrganizationRequest;
|
||||
|
||||
setLoading(false);
|
||||
setPostOrganizationRequestStatus(Status);
|
||||
|
||||
switch (Status) {
|
||||
case CheckoutStatus.Completed:
|
||||
await router.push(`/orgs/${Slug}/projects`);
|
||||
break;
|
||||
|
||||
case CheckoutStatus.Expired:
|
||||
throw new Error('Request to create organization has expired');
|
||||
|
||||
case CheckoutStatus.Open:
|
||||
// TODO discuss what to do in this case
|
||||
throw new Error(
|
||||
'Request to create organization with status "Open"',
|
||||
);
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Processing new organization request',
|
||||
successMessage:
|
||||
'The new organization has been created successfully.',
|
||||
errorMessage:
|
||||
'An error occurred while creating the new organization.',
|
||||
},
|
||||
);
|
||||
}
|
||||
})();
|
||||
}, [session_id, postOrganizationRequest, router, fetchOrg, isAuthenticated]);
|
||||
const [loading, status] = useFinishOrgCreation({ onCompleted });
|
||||
|
||||
return (
|
||||
<BaseLayout className="flex h-screen flex-col">
|
||||
<Header className="flex py-1" />
|
||||
<div className="flex h-screen w-full flex-col">
|
||||
<RetryableErrorBoundary errorMessageProps={{ className: 'pt-20' }}>
|
||||
<div className="relative flex flex-auto overflow-x-hidden">
|
||||
{loading && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center space-y-2">
|
||||
<ActivityIndicator
|
||||
circularProgressProps={{ className: 'w-6 h-6' }}
|
||||
/>
|
||||
<span>Processing new organization request</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
postOrganizationRequestStatus === CheckoutStatus.Completed && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center space-y-2">
|
||||
<ActivityIndicator
|
||||
circularProgressProps={{ className: 'w-6 h-6' }}
|
||||
/>
|
||||
<span>Organization created successfully. Redirecting...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
postOrganizationRequestStatus === CheckoutStatus.Expired && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center space-y-2">
|
||||
<span>
|
||||
Error occurred while creating the organization. Please try
|
||||
again.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
postOrganizationRequestStatus === CheckoutStatus.Open && (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center space-y-2">
|
||||
<span>Organization creation is pending...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RetryableErrorBoundary>
|
||||
<FinishOrgCreationProcess
|
||||
loading={loading}
|
||||
status={status}
|
||||
loadingMessage="Processing new organization request"
|
||||
successMessage="Organization created successfully. Redirecting..."
|
||||
pendingMessage="Organization creation is pending..."
|
||||
errorMessage="Error occurred while creating the organization. Please try again."
|
||||
/>
|
||||
</div>
|
||||
</BaseLayout>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import matchers from '@testing-library/jest-dom/matchers';
|
||||
import fetch from 'node-fetch';
|
||||
import { expect } from 'vitest';
|
||||
import { expect, vi } from 'vitest';
|
||||
|
||||
// Mock the ResizeObserver
|
||||
const ResizeObserverMock = vi.fn(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}));
|
||||
|
||||
// Stub the global ResizeObserver
|
||||
vi.stubGlobal('ResizeObserver', ResizeObserverMock);
|
||||
|
||||
// @ts-ignore
|
||||
global.fetch = fetch;
|
||||
|
||||
@@ -141,3 +141,56 @@ export const mockOrganization: Organization = {
|
||||
],
|
||||
__typename: 'organizations',
|
||||
};
|
||||
|
||||
export const mockOrganizations: Organization[] = [
|
||||
{
|
||||
id: 'org-1',
|
||||
name: "User's Personal Organization",
|
||||
slug: 'ynrnjteywyxjhlmgzgif',
|
||||
status: Organization_Status_Enum.Ok,
|
||||
plan: {
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: 0,
|
||||
deprecated: false,
|
||||
individual: true,
|
||||
isFree: true,
|
||||
featureMaxDbSize: 1,
|
||||
__typename: 'plans',
|
||||
},
|
||||
apps: [],
|
||||
members: [],
|
||||
__typename: 'organizations',
|
||||
},
|
||||
{
|
||||
id: 'org-2',
|
||||
name: 'Second First Try',
|
||||
slug: 'kbdoxvsoisppkrwzjhwl',
|
||||
status: Organization_Status_Enum.Ok,
|
||||
plan: {
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: 25,
|
||||
deprecated: false,
|
||||
individual: false,
|
||||
isFree: false,
|
||||
featureMaxDbSize: 10,
|
||||
__typename: 'plans',
|
||||
},
|
||||
apps: [],
|
||||
members: [],
|
||||
__typename: 'organizations',
|
||||
},
|
||||
];
|
||||
|
||||
export const newOrg: Organization = {
|
||||
...mockOrganization,
|
||||
id: 'newOrg',
|
||||
name: 'New Org',
|
||||
slug: 'new-org',
|
||||
};
|
||||
|
||||
export const mockOrganizationsWithNewOrg: Organization[] = [
|
||||
...mockOrganizations,
|
||||
newOrg,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { mockOrganization, mockOrganizations } from '@/tests/mocks';
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export const getOrganizations = nhostGraphQLLink.query(
|
||||
'getOrganizations',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
organizations: mockOrganizations,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const getOrganization = nhostGraphQLLink.query(
|
||||
'getOrganization',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
organizations: [{ ...mockOrganization }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
12
dashboard/src/tests/msw/mocks/graphql/getProjectQuery.ts
Normal file
12
dashboard/src/tests/msw/mocks/graphql/getProjectQuery.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { mockApplication } from '@/tests/mocks';
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export const getProjectQuery = nhostGraphQLLink.query(
|
||||
'getProject',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
apps: [{ ...mockApplication, githubRepository: null }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -1,5 +1,7 @@
|
||||
import { graphql } from 'msw';
|
||||
|
||||
const nhostGraphQLLink = graphql.link('https://local.graphql.nhost.run/v1');
|
||||
const nhostGraphQLLink = graphql.link(
|
||||
'https://local.graphql.local.nhost.run/v1',
|
||||
);
|
||||
|
||||
export default nhostGraphQLLink;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export const organizationMemberInvites = nhostGraphQLLink.query(
|
||||
'organizationMemberInvites',
|
||||
(_req, res, ctx) => res(ctx.data({ organizationMemberInvites: [] })),
|
||||
);
|
||||
|
||||
export const organizationNewRequests = nhostGraphQLLink.query(
|
||||
'organizationNewRequests',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
organizationNewRequests: [
|
||||
{
|
||||
id: 'org-request-id-1',
|
||||
sessionID: 'session-id-1',
|
||||
__typename: 'organization_new_request',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
73
dashboard/src/tests/msw/mocks/graphql/prefetchNewAppQuery.ts
Normal file
73
dashboard/src/tests/msw/mocks/graphql/prefetchNewAppQuery.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import nhostGraphQLLink from './nhostGraphQLLink';
|
||||
|
||||
export const prefetchNewAppQuery = nhostGraphQLLink.query(
|
||||
'PrefetchNewApp',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.data({
|
||||
regions: [
|
||||
{
|
||||
id: 'dd6f8e01-35a9-4ba6-8dc6-ed972f2db93c',
|
||||
city: 'Frankfurt',
|
||||
active: true,
|
||||
country: { code: 'DE', name: 'Germany', __typename: 'countries' },
|
||||
__typename: 'regions',
|
||||
},
|
||||
{
|
||||
id: 'd44dc594-022f-4aa7-84b2-0cebee5f1d13',
|
||||
city: 'London',
|
||||
active: false,
|
||||
country: {
|
||||
code: 'GB',
|
||||
name: 'United Kingdom',
|
||||
__typename: 'countries',
|
||||
},
|
||||
__typename: 'regions',
|
||||
},
|
||||
{
|
||||
id: '55985cd4-af14-4d2a-90a5-2a1253ebc1db',
|
||||
city: 'N. Virginia',
|
||||
active: true,
|
||||
country: {
|
||||
code: 'US',
|
||||
name: 'United States of America',
|
||||
__typename: 'countries',
|
||||
},
|
||||
__typename: 'regions',
|
||||
},
|
||||
{
|
||||
id: '8fe50a9b-ef48-459c-9dd0-a2fd28968224',
|
||||
city: 'Singapore',
|
||||
active: false,
|
||||
country: { code: 'SG', name: 'Singapore', __typename: 'countries' },
|
||||
__typename: 'regions',
|
||||
},
|
||||
],
|
||||
plans: [
|
||||
{
|
||||
id: 'dc5e805e-1bef-4d43-809e-9fdf865e211a',
|
||||
name: 'Pro',
|
||||
isDefault: false,
|
||||
isFree: false,
|
||||
price: 25,
|
||||
featureBackupEnabled: true,
|
||||
featureCustomDomainsEnabled: true,
|
||||
featureMaxDbSize: 10,
|
||||
__typename: 'plans',
|
||||
},
|
||||
{
|
||||
id: '9860b992-5658-4031-8580-d8135e18db7c',
|
||||
name: 'Team',
|
||||
isDefault: false,
|
||||
isFree: false,
|
||||
price: 599,
|
||||
featureBackupEnabled: true,
|
||||
featureCustomDomainsEnabled: true,
|
||||
featureMaxDbSize: 10,
|
||||
__typename: 'plans',
|
||||
},
|
||||
],
|
||||
workspaces: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
const hasuraMetadataQuery = rest.post(
|
||||
'https://local.hasura.nhost.run/v1/metadata',
|
||||
'https://local.hasura.local.nhost.run/v1/metadata',
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.delay(250),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rest } from 'msw';
|
||||
|
||||
const tableQuery = rest.post(
|
||||
'https://local.hasura.nhost.run/v2/query',
|
||||
'https://local.hasura.local.nhost.run/v2/query',
|
||||
async (req, res, ctx) => {
|
||||
const body = await req.json();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { NhostSession } from '@nhost/nextjs';
|
||||
import { rest } from 'msw';
|
||||
|
||||
const tokenQuery = rest.post(
|
||||
`https://local.auth.nhost.run/v1/token`,
|
||||
`https://local.auth.local.nhost.run/v1/token`,
|
||||
(_req, res, ctx) =>
|
||||
res(
|
||||
ctx.json<NhostSession>({
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
import { RouterContext } from 'next/dist/shared/lib/router-context.shared-runtime';
|
||||
import type { PropsWithChildren, ReactElement } from 'react';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { vi } from 'vitest';
|
||||
import nhostGraphQLLink from '../msw/mocks/graphql/nhostGraphQLLink';
|
||||
|
||||
// Client-side cache, shared for the whole session of the user in the browser.
|
||||
const emotionCache = createEmotionCache();
|
||||
@@ -33,13 +35,14 @@ process.env = {
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||
NEXT_PUBLIC_ENV: 'dev',
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL: 'https://local.auth.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL: 'https://local.functions.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL: 'https://local.graphql.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL: 'https://local.storage.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL: 'https://local.auth.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL: 'https://local.functions.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL: 'https://local.graphql.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL: 'https://local.storage.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL: 'https://local.hasura.local.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL:
|
||||
'https://local.hasura.local.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'https://local.hasura.local.nhost.run',
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -66,7 +69,7 @@ function Providers({ children }: PropsWithChildren<{}>) {
|
||||
nhost={nhost}
|
||||
generateLinks={() => [
|
||||
createHttpLink({
|
||||
uri: 'https://local.graphql.nhost.run/v1',
|
||||
uri: 'https://local.graphql.local.nhost.run/v1',
|
||||
}),
|
||||
]}
|
||||
>
|
||||
@@ -109,5 +112,53 @@ async function waitForElementToBeRemoved<T>(
|
||||
}
|
||||
}
|
||||
|
||||
const graphqlRequestHandlerFactory = (
|
||||
operationName: string,
|
||||
type: 'mutation' | 'query',
|
||||
responsePromise,
|
||||
) =>
|
||||
nhostGraphQLLink[type](operationName, async (_req, res, ctx) => {
|
||||
const data = await responsePromise;
|
||||
return res(ctx.data(data));
|
||||
});
|
||||
/* Helper function to pause responses to be able to test loading states */
|
||||
export const createGraphqlMockResolver = (
|
||||
operationName: string,
|
||||
type: 'mutation' | 'query',
|
||||
defaultResponse?: any,
|
||||
) => {
|
||||
let resolver;
|
||||
const responsePromise = new Promise((resolve) => {
|
||||
resolver = resolve;
|
||||
});
|
||||
|
||||
return {
|
||||
handler: graphqlRequestHandlerFactory(operationName, type, responsePromise),
|
||||
resolve: (response = undefined) => resolver(response ?? defaultResponse),
|
||||
};
|
||||
};
|
||||
|
||||
export const mockPointerEvent = () => {
|
||||
// Note: Workaround based on https://github.com/radix-ui/primitives/issues/1382#issuecomment-1122069313
|
||||
class MockPointerEvent extends Event {
|
||||
button: number;
|
||||
|
||||
ctrlKey: boolean;
|
||||
|
||||
pointerType: string;
|
||||
|
||||
constructor(type: string, props: PointerEventInit) {
|
||||
super(type, props);
|
||||
this.button = props.button || 0;
|
||||
this.ctrlKey = props.ctrlKey || false;
|
||||
this.pointerType = props.pointerType || 'mouse';
|
||||
}
|
||||
}
|
||||
window.PointerEvent = MockPointerEvent as any;
|
||||
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||
window.HTMLElement.prototype.hasPointerCapture = vi.fn();
|
||||
};
|
||||
|
||||
export * from '@testing-library/react';
|
||||
export { render, waitForElementToBeRemoved };
|
||||
|
||||
@@ -33,13 +33,14 @@ process.env = {
|
||||
NODE_ENV: 'development',
|
||||
NEXT_PUBLIC_NHOST_PLATFORM: 'false',
|
||||
NEXT_PUBLIC_ENV: 'dev',
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL: 'https://local.auth.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL: 'https://local.functions.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL: 'https://local.graphql.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL: 'https://local.storage.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'https://local.hasura.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_AUTH_URL: 'https://local.auth.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_FUNCTIONS_URL: 'https://local.functions.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_GRAPHQL_URL: 'https://local.graphql.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_STORAGE_URL: 'https://local.storage.local.nhost.run/v1',
|
||||
NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL: 'https://local.hasura.local.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_MIGRATIONS_API_URL:
|
||||
'https://local.hasura.local.nhost.run',
|
||||
NEXT_PUBLIC_NHOST_HASURA_API_URL: 'https://local.hasura.local.nhost.run',
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
@@ -66,7 +67,7 @@ function Providers({ children }: PropsWithChildren<{}>) {
|
||||
nhost={nhost}
|
||||
generateLinks={() => [
|
||||
createHttpLink({
|
||||
uri: 'https://local.graphql.nhost.run/v1',
|
||||
uri: 'https://local.graphql.local.nhost.run/v1',
|
||||
}),
|
||||
]}
|
||||
>
|
||||
|
||||
123
dashboard/src/utils/__generated__/graphql.ts
generated
123
dashboard/src/utils/__generated__/graphql.ts
generated
@@ -1283,7 +1283,7 @@ export type ConfigConfig = {
|
||||
/** Configuration for observability service */
|
||||
observability: ConfigObservability;
|
||||
/** Configuration for postgres service */
|
||||
postgres?: Maybe<ConfigPostgres>;
|
||||
postgres: ConfigPostgres;
|
||||
/** Configuration for third party providers like SMTP, SMS, etc. */
|
||||
provider?: Maybe<ConfigProvider>;
|
||||
/** Configuration for storage service */
|
||||
@@ -1314,7 +1314,7 @@ export type ConfigConfigInsertInput = {
|
||||
graphql?: InputMaybe<ConfigGraphqlInsertInput>;
|
||||
hasura: ConfigHasuraInsertInput;
|
||||
observability: ConfigObservabilityInsertInput;
|
||||
postgres?: InputMaybe<ConfigPostgresInsertInput>;
|
||||
postgres: ConfigPostgresInsertInput;
|
||||
provider?: InputMaybe<ConfigProviderInsertInput>;
|
||||
storage?: InputMaybe<ConfigStorageInsertInput>;
|
||||
};
|
||||
@@ -2247,7 +2247,7 @@ export type ConfigPortComparisonExp = {
|
||||
export type ConfigPostgres = {
|
||||
__typename?: 'ConfigPostgres';
|
||||
/** Resources for the service */
|
||||
resources?: Maybe<ConfigPostgresResources>;
|
||||
resources: ConfigPostgresResources;
|
||||
settings?: Maybe<ConfigPostgresSettings>;
|
||||
/**
|
||||
* Version of postgres, you can see available versions in the URL below:
|
||||
@@ -2266,7 +2266,7 @@ export type ConfigPostgresComparisonExp = {
|
||||
};
|
||||
|
||||
export type ConfigPostgresInsertInput = {
|
||||
resources?: InputMaybe<ConfigPostgresResourcesInsertInput>;
|
||||
resources: ConfigPostgresResourcesInsertInput;
|
||||
settings?: InputMaybe<ConfigPostgresSettingsInsertInput>;
|
||||
version?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
@@ -2274,43 +2274,50 @@ export type ConfigPostgresInsertInput = {
|
||||
/** Resources for the service */
|
||||
export type ConfigPostgresResources = {
|
||||
__typename?: 'ConfigPostgresResources';
|
||||
autoscaler?: Maybe<ConfigAutoscaler>;
|
||||
compute?: Maybe<ConfigResourcesCompute>;
|
||||
enablePublicAccess?: Maybe<Scalars['Boolean']>;
|
||||
networking?: Maybe<ConfigNetworking>;
|
||||
/** Number of replicas for a service */
|
||||
replicas?: Maybe<Scalars['ConfigUint8']>;
|
||||
storage?: Maybe<ConfigPostgresStorage>;
|
||||
storage: ConfigPostgresResourcesStorage;
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigPostgresResourcesComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigPostgresResourcesComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigPostgresResourcesComparisonExp>>;
|
||||
autoscaler?: InputMaybe<ConfigAutoscalerComparisonExp>;
|
||||
compute?: InputMaybe<ConfigResourcesComputeComparisonExp>;
|
||||
enablePublicAccess?: InputMaybe<ConfigBooleanComparisonExp>;
|
||||
networking?: InputMaybe<ConfigNetworkingComparisonExp>;
|
||||
replicas?: InputMaybe<ConfigUint8ComparisonExp>;
|
||||
storage?: InputMaybe<ConfigPostgresStorageComparisonExp>;
|
||||
storage?: InputMaybe<ConfigPostgresResourcesStorageComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesInsertInput = {
|
||||
autoscaler?: InputMaybe<ConfigAutoscalerInsertInput>;
|
||||
compute?: InputMaybe<ConfigResourcesComputeInsertInput>;
|
||||
enablePublicAccess?: InputMaybe<Scalars['Boolean']>;
|
||||
networking?: InputMaybe<ConfigNetworkingInsertInput>;
|
||||
replicas?: InputMaybe<Scalars['ConfigUint8']>;
|
||||
storage?: InputMaybe<ConfigPostgresStorageInsertInput>;
|
||||
storage: ConfigPostgresResourcesStorageInsertInput;
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesStorage = {
|
||||
__typename?: 'ConfigPostgresResourcesStorage';
|
||||
capacity: Scalars['ConfigUint32'];
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesStorageComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigPostgresResourcesStorageComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigPostgresResourcesStorageComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigPostgresResourcesStorageComparisonExp>>;
|
||||
capacity?: InputMaybe<ConfigUint32ComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesStorageInsertInput = {
|
||||
capacity: Scalars['ConfigUint32'];
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesStorageUpdateInput = {
|
||||
capacity?: InputMaybe<Scalars['ConfigUint32']>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresResourcesUpdateInput = {
|
||||
autoscaler?: InputMaybe<ConfigAutoscalerUpdateInput>;
|
||||
compute?: InputMaybe<ConfigResourcesComputeUpdateInput>;
|
||||
enablePublicAccess?: InputMaybe<Scalars['Boolean']>;
|
||||
networking?: InputMaybe<ConfigNetworkingUpdateInput>;
|
||||
replicas?: InputMaybe<Scalars['ConfigUint8']>;
|
||||
storage?: InputMaybe<ConfigPostgresStorageUpdateInput>;
|
||||
storage?: InputMaybe<ConfigPostgresResourcesStorageUpdateInput>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresSettings = {
|
||||
@@ -2413,27 +2420,6 @@ export type ConfigPostgresSettingsUpdateInput = {
|
||||
workMem?: InputMaybe<Scalars['String']>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresStorage = {
|
||||
__typename?: 'ConfigPostgresStorage';
|
||||
/** GiB */
|
||||
capacity: Scalars['ConfigUint32'];
|
||||
};
|
||||
|
||||
export type ConfigPostgresStorageComparisonExp = {
|
||||
_and?: InputMaybe<Array<ConfigPostgresStorageComparisonExp>>;
|
||||
_not?: InputMaybe<ConfigPostgresStorageComparisonExp>;
|
||||
_or?: InputMaybe<Array<ConfigPostgresStorageComparisonExp>>;
|
||||
capacity?: InputMaybe<ConfigUint32ComparisonExp>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresStorageInsertInput = {
|
||||
capacity: Scalars['ConfigUint32'];
|
||||
};
|
||||
|
||||
export type ConfigPostgresStorageUpdateInput = {
|
||||
capacity?: InputMaybe<Scalars['ConfigUint32']>;
|
||||
};
|
||||
|
||||
export type ConfigPostgresUpdateInput = {
|
||||
resources?: InputMaybe<ConfigPostgresResourcesUpdateInput>;
|
||||
settings?: InputMaybe<ConfigPostgresSettingsUpdateInput>;
|
||||
@@ -27633,7 +27619,7 @@ export type GetAiSettingsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetAiSettingsQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', postgres?: { __typename?: 'ConfigPostgres', version?: string | null } | null, ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', apiKey: string, organization?: string | null }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } | null };
|
||||
export type GetAiSettingsQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', postgres: { __typename?: 'ConfigPostgres', version?: string | null }, ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', apiKey: string, organization?: string | null }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } | null };
|
||||
|
||||
export type GetAuthenticationSettingsQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
@@ -27647,7 +27633,7 @@ export type GetPostgresSettingsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetPostgresSettingsQuery = { __typename?: 'query_root', systemConfig?: { __typename?: 'ConfigSystemConfig', postgres: { __typename?: 'ConfigSystemConfigPostgres', database: string } } | null, config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', postgres?: { __typename?: 'ConfigPostgres', version?: string | null, resources?: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage?: { __typename?: 'ConfigPostgresStorage', capacity: any } | null } | null } | null } | null };
|
||||
export type GetPostgresSettingsQuery = { __typename?: 'query_root', systemConfig?: { __typename?: 'ConfigSystemConfig', postgres: { __typename?: 'ConfigSystemConfigPostgres', database: string } } | null, config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', postgres: { __typename?: 'ConfigPostgres', version?: string | null, resources: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage: { __typename?: 'ConfigPostgresResourcesStorage', capacity: any } } } } | null };
|
||||
|
||||
export type ResetDatabasePasswordMutationVariables = Exact<{
|
||||
appId: Scalars['String'];
|
||||
@@ -27703,14 +27689,14 @@ export type GetObservabilitySettingsQueryVariables = Exact<{
|
||||
|
||||
export type GetObservabilitySettingsQuery = { __typename?: 'query_root', config?: { __typename: 'ConfigConfig', id: 'ConfigConfig', observability: { __typename?: 'ConfigObservability', grafana: { __typename?: 'ConfigGrafana', alerting?: { __typename?: 'ConfigGrafanaAlerting', enabled?: boolean | null } | null, smtp?: { __typename?: 'ConfigGrafanaSmtp', host: string, password: string, port: any, sender: string, user: string } | null, contacts?: { __typename?: 'ConfigGrafanaContacts', emails?: Array<string> | null, discord?: Array<{ __typename?: 'ConfigGrafanacontactsDiscord', avatarUrl: string, url: string }> | null, pagerduty?: Array<{ __typename?: 'ConfigGrafanacontactsPagerduty', integrationKey: string, severity: string, class: string, component: string, group: string }> | null, slack?: Array<{ __typename?: 'ConfigGrafanacontactsSlack', recipient: string, token: string, username: string, iconEmoji: string, iconURL: string, mentionUsers: Array<string>, mentionGroups: Array<string>, mentionChannel: string, url: string, endpointURL: string }> | null, webhook?: Array<{ __typename?: 'ConfigGrafanacontactsWebhook', url: string, httpMethod: string, username: string, password: string, authorizationScheme: string, authorizationCredentials: string, maxAlerts: number }> | null } | null } } } | null };
|
||||
|
||||
export type ServiceResourcesFragment = { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, replicas?: any | null, storage?: { __typename?: 'ConfigPostgresStorage', capacity: any } | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null };
|
||||
export type ServiceResourcesFragment = { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null }, postgres: { __typename?: 'ConfigPostgres', resources: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage: { __typename?: 'ConfigPostgresResourcesStorage', capacity: any }, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } }, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null };
|
||||
|
||||
export type GetResourcesQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
}>;
|
||||
|
||||
|
||||
export type GetResourcesQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null }, postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, replicas?: any | null, storage?: { __typename?: 'ConfigPostgresStorage', capacity: any } | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null } | null };
|
||||
export type GetResourcesQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null } | null, hasura: { __typename?: 'ConfigHasura', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null, networking?: { __typename?: 'ConfigNetworking', ingresses?: Array<{ __typename?: 'ConfigIngress', fqdn?: Array<string> | null }> | null } | null } | null }, postgres: { __typename?: 'ConfigPostgres', resources: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage: { __typename?: 'ConfigPostgresResourcesStorage', capacity: any }, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null } }, storage?: { __typename?: 'ConfigStorage', resources?: { __typename?: 'ConfigResources', replicas?: any | null, compute?: { __typename?: 'ConfigResourcesCompute', cpu: any, memory: any } | null, autoscaler?: { __typename?: 'ConfigAutoscaler', maxReplicas: any } | null } | null } | null } | null };
|
||||
|
||||
export type GetStorageSettingsQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
@@ -27770,7 +27756,7 @@ export type GetConfiguredVersionsQueryVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type GetConfiguredVersionsQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', version?: string | null } | null, postgres?: { __typename?: 'ConfigPostgres', version?: string | null } | null, hasura: { __typename?: 'ConfigHasura', version?: string | null }, ai?: { __typename?: 'ConfigAI', version?: string | null } | null, storage?: { __typename?: 'ConfigStorage', version?: string | null } | null } | null };
|
||||
export type GetConfiguredVersionsQuery = { __typename?: 'query_root', config?: { __typename?: 'ConfigConfig', auth?: { __typename?: 'ConfigAuth', version?: string | null } | null, postgres: { __typename?: 'ConfigPostgres', version?: string | null }, hasura: { __typename?: 'ConfigHasura', version?: string | null }, ai?: { __typename?: 'ConfigAI', version?: string | null } | null, storage?: { __typename?: 'ConfigStorage', version?: string | null } | null } | null };
|
||||
|
||||
export type GetProjectIsLockedQueryVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
@@ -27969,7 +27955,7 @@ export type UpdateConfigMutationVariables = Exact<{
|
||||
}>;
|
||||
|
||||
|
||||
export type UpdateConfigMutation = { __typename?: 'mutation_root', updateConfig: { __typename?: 'ConfigConfig', id: 'ConfigConfig', postgres?: { __typename?: 'ConfigPostgres', resources?: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage?: { __typename?: 'ConfigPostgresStorage', capacity: any } | null } | null } | null, ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', organization?: string | null, apiKey: string }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } };
|
||||
export type UpdateConfigMutation = { __typename?: 'mutation_root', updateConfig: { __typename?: 'ConfigConfig', id: 'ConfigConfig', postgres: { __typename?: 'ConfigPostgres', resources: { __typename?: 'ConfigPostgresResources', enablePublicAccess?: boolean | null, storage: { __typename?: 'ConfigPostgresResourcesStorage', capacity: any } } }, ai?: { __typename?: 'ConfigAI', version?: string | null, webhookSecret: string, autoEmbeddings?: { __typename?: 'ConfigAIAutoEmbeddings', synchPeriodMinutes?: any | null } | null, openai: { __typename?: 'ConfigAIOpenai', organization?: string | null, apiKey: string }, resources: { __typename?: 'ConfigAIResources', compute: { __typename?: 'ConfigComputeResources', cpu: any, memory: any } } } | null } };
|
||||
|
||||
export type UpdateDatabaseVersionMutationVariables = Exact<{
|
||||
appId: Scalars['uuid'];
|
||||
@@ -28435,14 +28421,14 @@ export type GetRemoteAppMetricsQuery = { __typename?: 'query_root', filesAggrega
|
||||
|
||||
export type RemoteAppGetUsersFragment = { __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, emailVerified: boolean, phoneNumber?: string | null, phoneNumberVerified: boolean, disabled: boolean, defaultRole: string, lastSeen?: any | null, locale: string, metadata?: any | null, roles: Array<{ __typename?: 'authUserRoles', id: any, role: string }>, userProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> };
|
||||
|
||||
export type RemoteAppGetUsersQueryVariables = Exact<{
|
||||
export type RemoteAppGetUsersAndAuthRolesQueryVariables = Exact<{
|
||||
where: Users_Bool_Exp;
|
||||
limit: Scalars['Int'];
|
||||
offset: Scalars['Int'];
|
||||
}>;
|
||||
|
||||
|
||||
export type RemoteAppGetUsersQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, emailVerified: boolean, phoneNumber?: string | null, phoneNumberVerified: boolean, disabled: boolean, defaultRole: string, lastSeen?: any | null, locale: string, metadata?: any | null, roles: Array<{ __typename?: 'authUserRoles', id: any, role: string }>, userProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> }>, filteredUsersAggreggate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, usersAggregate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null } };
|
||||
export type RemoteAppGetUsersAndAuthRolesQuery = { __typename?: 'query_root', users: Array<{ __typename?: 'users', id: any, createdAt: any, displayName: string, avatarUrl: string, email?: any | null, emailVerified: boolean, phoneNumber?: string | null, phoneNumberVerified: boolean, disabled: boolean, defaultRole: string, lastSeen?: any | null, locale: string, metadata?: any | null, roles: Array<{ __typename?: 'authUserRoles', id: any, role: string }>, userProviders: Array<{ __typename?: 'authUserProviders', id: any, providerId: string }> }>, filteredUsersAggreggate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, usersAggregate: { __typename?: 'users_aggregate', aggregate?: { __typename?: 'users_aggregate_fields', count: number } | null }, authRoles: Array<{ __typename?: 'authRoles', role: string }> };
|
||||
|
||||
export type RemoteAppGetUsersCustomQueryVariables = Exact<{
|
||||
where: Users_Bool_Exp;
|
||||
@@ -28721,10 +28707,6 @@ export const ServiceResourcesFragmentDoc = gql`
|
||||
cpu
|
||||
memory
|
||||
}
|
||||
replicas
|
||||
autoscaler {
|
||||
maxReplicas
|
||||
}
|
||||
}
|
||||
}
|
||||
storage {
|
||||
@@ -34044,8 +34026,8 @@ export type GetRemoteAppMetricsQueryResult = Apollo.QueryResult<GetRemoteAppMetr
|
||||
export function refetchGetRemoteAppMetricsQuery(variables?: GetRemoteAppMetricsQueryVariables) {
|
||||
return { query: GetRemoteAppMetricsDocument, variables: variables }
|
||||
}
|
||||
export const RemoteAppGetUsersDocument = gql`
|
||||
query remoteAppGetUsers($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||
export const RemoteAppGetUsersAndAuthRolesDocument = gql`
|
||||
query remoteAppGetUsersAndAuthRoles($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||
users(
|
||||
where: $where
|
||||
limit: $limit
|
||||
@@ -34064,20 +34046,23 @@ export const RemoteAppGetUsersDocument = gql`
|
||||
count
|
||||
}
|
||||
}
|
||||
authRoles {
|
||||
role
|
||||
}
|
||||
}
|
||||
${RemoteAppGetUsersFragmentDoc}`;
|
||||
|
||||
/**
|
||||
* __useRemoteAppGetUsersQuery__
|
||||
* __useRemoteAppGetUsersAndAuthRolesQuery__
|
||||
*
|
||||
* To run a query within a React component, call `useRemoteAppGetUsersQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useRemoteAppGetUsersQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* To run a query within a React component, call `useRemoteAppGetUsersAndAuthRolesQuery` and pass it any options that fit your needs.
|
||||
* When your component renders, `useRemoteAppGetUsersAndAuthRolesQuery` returns an object from Apollo Client that contains loading, error, and data properties
|
||||
* you can use to render your UI.
|
||||
*
|
||||
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
|
||||
*
|
||||
* @example
|
||||
* const { data, loading, error } = useRemoteAppGetUsersQuery({
|
||||
* const { data, loading, error } = useRemoteAppGetUsersAndAuthRolesQuery({
|
||||
* variables: {
|
||||
* where: // value for 'where'
|
||||
* limit: // value for 'limit'
|
||||
@@ -34085,19 +34070,19 @@ export const RemoteAppGetUsersDocument = gql`
|
||||
* },
|
||||
* });
|
||||
*/
|
||||
export function useRemoteAppGetUsersQuery(baseOptions: Apollo.QueryHookOptions<RemoteAppGetUsersQuery, RemoteAppGetUsersQueryVariables>) {
|
||||
export function useRemoteAppGetUsersAndAuthRolesQuery(baseOptions: Apollo.QueryHookOptions<RemoteAppGetUsersAndAuthRolesQuery, RemoteAppGetUsersAndAuthRolesQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useQuery<RemoteAppGetUsersQuery, RemoteAppGetUsersQueryVariables>(RemoteAppGetUsersDocument, options);
|
||||
return Apollo.useQuery<RemoteAppGetUsersAndAuthRolesQuery, RemoteAppGetUsersAndAuthRolesQueryVariables>(RemoteAppGetUsersAndAuthRolesDocument, options);
|
||||
}
|
||||
export function useRemoteAppGetUsersLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<RemoteAppGetUsersQuery, RemoteAppGetUsersQueryVariables>) {
|
||||
export function useRemoteAppGetUsersAndAuthRolesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<RemoteAppGetUsersAndAuthRolesQuery, RemoteAppGetUsersAndAuthRolesQueryVariables>) {
|
||||
const options = {...defaultOptions, ...baseOptions}
|
||||
return Apollo.useLazyQuery<RemoteAppGetUsersQuery, RemoteAppGetUsersQueryVariables>(RemoteAppGetUsersDocument, options);
|
||||
return Apollo.useLazyQuery<RemoteAppGetUsersAndAuthRolesQuery, RemoteAppGetUsersAndAuthRolesQueryVariables>(RemoteAppGetUsersAndAuthRolesDocument, options);
|
||||
}
|
||||
export type RemoteAppGetUsersQueryHookResult = ReturnType<typeof useRemoteAppGetUsersQuery>;
|
||||
export type RemoteAppGetUsersLazyQueryHookResult = ReturnType<typeof useRemoteAppGetUsersLazyQuery>;
|
||||
export type RemoteAppGetUsersQueryResult = Apollo.QueryResult<RemoteAppGetUsersQuery, RemoteAppGetUsersQueryVariables>;
|
||||
export function refetchRemoteAppGetUsersQuery(variables: RemoteAppGetUsersQueryVariables) {
|
||||
return { query: RemoteAppGetUsersDocument, variables: variables }
|
||||
export type RemoteAppGetUsersAndAuthRolesQueryHookResult = ReturnType<typeof useRemoteAppGetUsersAndAuthRolesQuery>;
|
||||
export type RemoteAppGetUsersAndAuthRolesLazyQueryHookResult = ReturnType<typeof useRemoteAppGetUsersAndAuthRolesLazyQuery>;
|
||||
export type RemoteAppGetUsersAndAuthRolesQueryResult = Apollo.QueryResult<RemoteAppGetUsersAndAuthRolesQuery, RemoteAppGetUsersAndAuthRolesQueryVariables>;
|
||||
export function refetchRemoteAppGetUsersAndAuthRolesQuery(variables: RemoteAppGetUsersAndAuthRolesQueryVariables) {
|
||||
return { query: RemoteAppGetUsersAndAuthRolesDocument, variables: variables }
|
||||
}
|
||||
export const RemoteAppGetUsersCustomDocument = gql`
|
||||
query remoteAppGetUsersCustom($where: users_bool_exp!, $limit: Int!, $offset: Int!) {
|
||||
|
||||
19
dashboard/src/utils/env/env.ts
vendored
19
dashboard/src/utils/env/env.ts
vendored
@@ -17,7 +17,8 @@ export function getHasuraAdminSecret() {
|
||||
*/
|
||||
export function getAuthServiceUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_AUTH_URL || 'https://local.auth.nhost.run/v1'
|
||||
process.env.NEXT_PUBLIC_NHOST_AUTH_URL ||
|
||||
'https://local.auth.local.nhost.run/v1'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,7 +26,9 @@ export function getAuthServiceUrl() {
|
||||
* Custom URL of the Database service.
|
||||
*/
|
||||
export function getDatabaseServiceUrl() {
|
||||
return process.env.NEXT_PUBLIC_NHOST_DATABASE_URL || 'local.db.nhost.run';
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_DATABASE_URL || 'local.db.local.nhost.run'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,7 +37,7 @@ export function getDatabaseServiceUrl() {
|
||||
export function getGraphqlServiceUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_GRAPHQL_URL ||
|
||||
'https://local.graphql.nhost.run/v1'
|
||||
'https://local.graphql.local.nhost.run/v1'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,7 +47,7 @@ export function getGraphqlServiceUrl() {
|
||||
export function getStorageServiceUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_STORAGE_URL ||
|
||||
'https://local.storage.nhost.run/v1'
|
||||
'https://local.storage.local.nhost.run/v1'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +57,7 @@ export function getStorageServiceUrl() {
|
||||
export function getFunctionsServiceUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_FUNCTIONS_URL ||
|
||||
'https://local.functions.nhost.run/v1'
|
||||
'https://local.functions.local.nhost.run/v1'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -64,7 +67,7 @@ export function getFunctionsServiceUrl() {
|
||||
export function getHasuraConsoleServiceUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_HASURA_CONSOLE_URL ||
|
||||
'https://local.hasura.nhost.run'
|
||||
'https://local.hasura.local.nhost.run'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,7 +87,7 @@ export function getHasuraMigrationsApiUrl() {
|
||||
export function getHasuraApiUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_HASURA_API_URL ||
|
||||
'https://local.hasura.nhost.run'
|
||||
'https://local.hasura.local.nhost.run'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,6 +97,6 @@ export function getHasuraApiUrl() {
|
||||
export function getConfigServerUrl() {
|
||||
return (
|
||||
process.env.NEXT_PUBLIC_NHOST_CONFIGSERVER_URL ||
|
||||
'https://local.dashboard.nhost.run/v1/configserver/graphql'
|
||||
'https://local.dashboard.local.nhost.run/v1/configserver/graphql'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
"@/components/*": ["components/*"],
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.28.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6b8163d: fix(nhost-js) update service URL generation for local environments
|
||||
|
||||
## 2.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -6,21 +6,21 @@ icon: rectangle-code
|
||||
|
||||
## `nhost init`
|
||||
|
||||
Initializes current directory with a new project containing configuration and objects necessary to run Nhost locally.
|
||||
Initializes current directory with a new project containing configuration and objects necessary to run Nhost locally.
|
||||
|
||||
<Accordion title="Output">
|
||||
|
||||
Initializing Nhost project
|
||||
Initializing Nhost project
|
||||
|
||||
Successfully initialized Nhost project, run `nhost up` to start development
|
||||
|
||||
Successfully initialized Nhost project, run `nhost up` to start development
|
||||
</Accordion>
|
||||
|
||||
### Flags
|
||||
### Flags
|
||||
|
||||
`--remote` optional `false` <br />
|
||||
Pulls state from a remote Nhost instance.
|
||||
|
||||
|
||||
## `nhost up`
|
||||
|
||||
Starts Nhost for development and testing purposes. Requires scaffolding from `nhost init`.
|
||||
@@ -31,13 +31,13 @@ Starts Nhost for development and testing purposes. Requires scaffolding from `nh
|
||||
Nhost development environment started.
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/local
|
||||
- Hasura: https://local.hasura.nhost.run
|
||||
- GraphQL: https://local.graphql.nhost.run
|
||||
- Auth: https://local.auth.nhost.run
|
||||
- Storage: https://local.storage.nhost.run
|
||||
- Functions: https://local.functions.nhost.run
|
||||
- Dashboard: https://local.dashboard.nhost.run
|
||||
- Mailhog: https://local.mailhog.nhost.run
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
@@ -50,26 +50,22 @@ Run `nhost logs` to watch the logs
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
|
||||
|
||||
## `nhost down`
|
||||
|
||||
Stops all services and deletes all containers.
|
||||
|
||||
### Flags
|
||||
### Flags
|
||||
|
||||
`--volumes` optional `false` <br />
|
||||
Remove volumes.
|
||||
|
||||
|
||||
## `nhost login`
|
||||
|
||||
Login to an Nhost account to perform authenticated operations.
|
||||
|
||||
<Accordion title="Output">
|
||||
- email: user@domain.com
|
||||
- password:
|
||||
- password:
|
||||
|
||||
Authenticating <br />
|
||||
Successfully logged in, creating PAT <br />
|
||||
@@ -78,7 +74,7 @@ Storing PAT for future user
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Flags
|
||||
### Flags
|
||||
|
||||
`--email` optional <br />
|
||||
Email adress.
|
||||
@@ -104,7 +100,7 @@ app-auth-1 | {"level":"info","message":"Running on port 4000"}
|
||||
|
||||
</Accordion>
|
||||
|
||||
### Flags
|
||||
### Flags
|
||||
|
||||
`--follow` optional <br />
|
||||
Follow (or tail) log output.
|
||||
@@ -146,6 +142,7 @@ List all remote Nhost projects this user has access to.
|
||||
12 │ xmhqdbhkvskuubnelgkc │ monitoring-app-london │ monitoring │ eu-west-2 │
|
||||
13 │ jjetetkbmovfgyewremk │ monitoring-app-mumbai │ monitoring │ ap-south-1 │
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
## `nhost secrets`
|
||||
@@ -159,4 +156,3 @@ Manage secrets in the Nhost cloud.
|
||||
<Accordion title="Output">
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ To install the Nhost CLI copy the following command and paste it into your termi
|
||||
sudo curl -L https://raw.githubusercontent.com/nhost/cli/main/get.sh | bash
|
||||
```
|
||||
|
||||
The `get.sh` script checks for both the architecture and operating system and installs the right binary.
|
||||
The `get.sh` script checks for both the architecture and operating system and installs the right binary.
|
||||
|
||||
### Supported Platforms:
|
||||
|
||||
@@ -56,13 +56,13 @@ With the project scaffolding in place, start the local Nhost instance with `nhos
|
||||
Nhost development environment started.
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/local
|
||||
- Hasura: https://local.hasura.nhost.run
|
||||
- GraphQL: https://local.graphql.nhost.run
|
||||
- Auth: https://local.auth.nhost.run
|
||||
- Storage: https://local.storage.nhost.run
|
||||
- Functions: https://local.functions.nhost.run
|
||||
- Dashboard: https://local.dashboard.nhost.run
|
||||
- Mailhog: https://local.mailhog.nhost.run
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
|
||||
@@ -4,9 +4,9 @@ description: 'Developing locally with the Nhost CLI'
|
||||
icon: hand-wave
|
||||
---
|
||||
|
||||
Run the Nhost Stack on your computer with `nhost init` and `nhost start`. The Nhost CLI ships with all the tools you need to run, test, and operate your projects.
|
||||
Run the Nhost Stack on your computer with `nhost init` and `nhost start`. The Nhost CLI ships with all the tools you need to run, test, and operate your projects.
|
||||
|
||||
Manage configuration, database migrations, API schema changes, and more, with ease.
|
||||
Manage configuration, database migrations, API schema changes, and more, with ease.
|
||||
|
||||
```bash
|
||||
> nhost up
|
||||
@@ -14,13 +14,13 @@ Manage configuration, database migrations, API schema changes, and more, with ea
|
||||
Nhost development environment started.
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/local
|
||||
- Hasura: https://local.hasura.nhost.run
|
||||
- GraphQL: https://local.graphql.nhost.run
|
||||
- Auth: https://local.auth.nhost.run
|
||||
- Storage: https://local.storage.nhost.run
|
||||
- Functions: https://local.functions.nhost.run
|
||||
- Dashboard: https://local.dashboard.nhost.run
|
||||
- Mailhog: https://local.mailhog.nhost.run
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: "Assistants"
|
||||
description: "Deploy your customized AI assistants"
|
||||
title: 'Assistants'
|
||||
description: 'Deploy your customized AI assistants'
|
||||
icon: wand-magic-sparkles
|
||||
---
|
||||
|
||||
@@ -23,110 +23,82 @@ The first step is to create an AI assistant. You can create an AI assistant by u
|
||||
```graphql
|
||||
mutation {
|
||||
graphite {
|
||||
insertAssistant(object: {
|
||||
name: "awesome-assistant-for-my-users",
|
||||
description: "My Awesome assistant for my users",
|
||||
instructions: "You are a useful assistant for this demo application. You are an expert on movies. You also know a bit about deep questions.",
|
||||
model: "gpt-3.5-turbo-1106",
|
||||
graphql: [
|
||||
{
|
||||
description: "get movies with higher score than the input"
|
||||
name: "GetMoviesWithScoreHigherThan",
|
||||
query: "query ($score: numeric!) { movies(where: {score: {_gt: $score}}) { name overview score }}",
|
||||
arguments: [
|
||||
{
|
||||
name: "score",
|
||||
description: "score to compare against",
|
||||
type: "number",
|
||||
required: true,
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
description: "search movies using natural language",
|
||||
name: "SearchMovies",
|
||||
query: "query GraphiteSearchMovies($query: String!) { graphiteSearchMovies(args: {query: $query, amount: 5}) { id name overview genre } }",
|
||||
arguments: [
|
||||
{
|
||||
name: "query",
|
||||
description: "Text to search",
|
||||
type: "string",
|
||||
required: true,
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
description: "insert a movie into the database",
|
||||
name: "InsertMovie",
|
||||
query: "mutation InsertMovie($name: String, $overview: String, $genre: String, $crew: String, $budget: bigint, $revenue: bigint, $country: String, $score: numeric) { insertMovie(object: {name: $name, overview: $overview, score: $score, genre: $genre, crew: $crew, budget: $budget, revenue: $revenue, country: $country}) {id}}",
|
||||
arguments: [
|
||||
{
|
||||
name: "name",
|
||||
description: "Name of the movie",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "overview",
|
||||
description: "Overview of the movie",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "genre",
|
||||
description: "Genre of the movie",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "crew",
|
||||
description: "Crew of the movie",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "budget",
|
||||
description: "Budget of the movie",
|
||||
type: "number",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "revenue",
|
||||
description: "Revenue generated by the movie",
|
||||
type: "number",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "country",
|
||||
description: "Country of origin for the movie",
|
||||
type: "string",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "score",
|
||||
description: "Score of the movie",
|
||||
type: "number",
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
],
|
||||
webhooks: [
|
||||
{
|
||||
name: "answer_deep_questions",
|
||||
description: "Answers deep questions about life, the universe and everything",
|
||||
URL: "https://local.functions.nhost.run/v1/meaning",
|
||||
arguments: [
|
||||
{
|
||||
name: "question",
|
||||
description: "question that needs answer",
|
||||
type: "string",
|
||||
required: true,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}) {
|
||||
insertAssistant(
|
||||
object: {
|
||||
name: "awesome-assistant-for-my-users"
|
||||
description: "My Awesome assistant for my users"
|
||||
instructions: "You are a useful assistant for this demo application. You are an expert on movies. You also know a bit about deep questions."
|
||||
model: "gpt-3.5-turbo-1106"
|
||||
graphql: [
|
||||
{
|
||||
description: "get movies with higher score than the input"
|
||||
name: "GetMoviesWithScoreHigherThan"
|
||||
query: "query ($score: numeric!) { movies(where: {score: {_gt: $score}}) { name overview score }}"
|
||||
arguments: [
|
||||
{
|
||||
name: "score"
|
||||
description: "score to compare against"
|
||||
type: "number"
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
{
|
||||
description: "search movies using natural language"
|
||||
name: "SearchMovies"
|
||||
query: "query GraphiteSearchMovies($query: String!) { graphiteSearchMovies(args: {query: $query, amount: 5}) { id name overview genre } }"
|
||||
arguments: [
|
||||
{ name: "query", description: "Text to search", type: "string", required: true }
|
||||
]
|
||||
}
|
||||
{
|
||||
description: "insert a movie into the database"
|
||||
name: "InsertMovie"
|
||||
query: "mutation InsertMovie($name: String, $overview: String, $genre: String, $crew: String, $budget: bigint, $revenue: bigint, $country: String, $score: numeric) { insertMovie(object: {name: $name, overview: $overview, score: $score, genre: $genre, crew: $crew, budget: $budget, revenue: $revenue, country: $country}) {id}}"
|
||||
arguments: [
|
||||
{ name: "name", description: "Name of the movie", type: "string", required: true }
|
||||
{
|
||||
name: "overview"
|
||||
description: "Overview of the movie"
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{ name: "genre", description: "Genre of the movie", type: "string", required: true }
|
||||
{ name: "crew", description: "Crew of the movie", type: "string", required: true }
|
||||
{ name: "budget", description: "Budget of the movie", type: "number", required: true }
|
||||
{
|
||||
name: "revenue"
|
||||
description: "Revenue generated by the movie"
|
||||
type: "number"
|
||||
required: true
|
||||
}
|
||||
{
|
||||
name: "country"
|
||||
description: "Country of origin for the movie"
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
{ name: "score", description: "Score of the movie", type: "number", required: true }
|
||||
]
|
||||
}
|
||||
]
|
||||
webhooks: [
|
||||
{
|
||||
name: "answer_deep_questions"
|
||||
description: "Answers deep questions about life, the universe and everything"
|
||||
URL: "https://local.functions.local.nhost.run/v1/meaning"
|
||||
arguments: [
|
||||
{
|
||||
name: "question"
|
||||
description: "question that needs answer"
|
||||
type: "string"
|
||||
required: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
) {
|
||||
assistantID
|
||||
}
|
||||
}
|
||||
@@ -140,7 +112,6 @@ mutation {
|
||||
|
||||
In order to enhance our AI assistants we are leveraging tools. Tools can be either queries or mutations performed against your project's GraphQL engine or they can also be any arbitrary endpoint you choose. Using the example above, we added 4 different tools to our AI Assistant:
|
||||
|
||||
|
||||
- The GraphQL query `GetMoviesWithScoreHigherThan`, which will allow the AI Assistant to query movies based on their score.
|
||||
- The GraphQL query `SearchMovies`, which leverages [auto-embeddings](auto-embeddings) and will allow the AI Assistant to query movies using natural language.
|
||||
- The GraphQL mutation `InsertMovie`, which will let the AI Assistant to insert new movies directly into the database.
|
||||
@@ -160,7 +131,7 @@ To start a session you can use the mutation `startSession` and pass the id of th
|
||||
```graphql
|
||||
mutation {
|
||||
graphite {
|
||||
startSession(assistantID:"asst_xolmq2yeBBRi8CKGc5YWXGaA") {
|
||||
startSession(assistantID: "asst_xolmq2yeBBRi8CKGc5YWXGaA") {
|
||||
sessionID
|
||||
}
|
||||
}
|
||||
@@ -188,10 +159,10 @@ Now that you have started a session you can send your first message:
|
||||
```graphql
|
||||
mutation {
|
||||
graphite {
|
||||
sendMessage(
|
||||
sessionID:"thread_dxHHNpQryFpJ8wXXD9Q2fQJg",
|
||||
sendMessage(
|
||||
sessionID: "thread_dxHHNpQryFpJ8wXXD9Q2fQJg"
|
||||
message: "what is the meaning of life?"
|
||||
prevMessageID: "",
|
||||
prevMessageID: ""
|
||||
) {
|
||||
sessionID
|
||||
messages {
|
||||
@@ -207,7 +178,6 @@ mutation {
|
||||
|
||||
The field `prevMessageID` is used to only return messages after this one. As this is the beginning of the conversation we send an empty string to get all messages back. After executing that mutation we should receive a response like:
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
@@ -249,10 +219,10 @@ We can continue the conversation by performing from mutations. Notice this time
|
||||
```graphql
|
||||
mutation {
|
||||
graphite {
|
||||
sendMessage(
|
||||
sessionID:"thread_dxHHNpQryFpJ8wXXD9Q2fQJg",
|
||||
sendMessage(
|
||||
sessionID: "thread_dxHHNpQryFpJ8wXXD9Q2fQJg"
|
||||
message: "recommend a comedy movie for a rainy day with score higher than 80"
|
||||
prevMessageID: "msg_kTZonuiwZDxnipDADXk4bgFd",
|
||||
prevMessageID: "msg_kTZonuiwZDxnipDADXk4bgFd"
|
||||
) {
|
||||
sessionID
|
||||
messages {
|
||||
@@ -302,7 +272,6 @@ In this case ChatGPT detected some of the information needed to formulate the an
|
||||
|
||||
Interactions are not limited to just one tool or to simply query data. Tools can be combined and they can perform any action you wish. For instance, let's take a look to the following session:
|
||||
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
|
||||
@@ -150,7 +150,7 @@ curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-hasura-admin-secret: nhost-admin-secret" \
|
||||
-d '{"userId": "FFAB5354-C5EB-42C1-8BC3-AD21D2297883", "defaultRole": "user", "allowedRoles": ["user", "me"]}' \
|
||||
https://local.functions.nhost.run/v1/custom-jwt
|
||||
https://local.functions.local.nhost.run/v1/custom-jwt
|
||||
{"accessToken":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTcxNDIyMTMsImh0dHBzOi8vaGFzdXJhLmlvL2p3dC9jbGFpbXMiOnsieC1oYXN1cmEtYWxsb3dlZC1yb2xlcyI6WyJ1c2VyIiwibWUiXSwieC1oYXN1cmEtZGVmYXVsdC1yb2xlIjoidXNlciIsIngtaGFzdXJhLXVzZXItaWQiOiJGRkFCNTM1NC1DNUVCLTQyQzEtOEJDMy1BRDIxRDIyOTc4ODMiLCJ4LWhhc3VyYS11c2VyLWlzLWFub255bW91cyI6ImZhbHNlIiwieC1oYXN1cmEtb24tYmVoYWxmLW9mIjoiYWRtaW4ifSwiaWF0IjoxNzE3MTM4NjEzLCJpc3MiOiJjdXN0b20tbGFtYmRhIiwic3ViIjoiRkZBQjUzNTQtQzVFQi00MkMxLThCQzMtQUQyMUQyMjk3ODgzIn0.bRhzJvXMdkQA8aXPH95uMT17WHED2rSRq3gE21Vp3Ak"}
|
||||
```
|
||||
|
||||
@@ -160,10 +160,7 @@ The new token should be a valid token for your application with the custom value
|
||||
{
|
||||
"exp": 1717141288,
|
||||
"https://hasura.io/jwt/claims": {
|
||||
"x-hasura-allowed-roles": [
|
||||
"a",
|
||||
"b"
|
||||
],
|
||||
"x-hasura-allowed-roles": ["a", "b"],
|
||||
"x-hasura-default-role": "user",
|
||||
"x-hasura-user-id": "FFAB5354-C5EB-42C1-8BC3-AD21D2297883",
|
||||
"x-hasura-user-is-anonymous": "false",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
---
|
||||
title: Local Development
|
||||
description: Learn how to start a development instance of Nhost
|
||||
description: Learn how to start a development instance of Nhost
|
||||
icon: code
|
||||
---
|
||||
|
||||
Nhost's command-line interface (CLI) lets you run a complete Nhost development environment locally with the following services: PostgreSQL database, Hasura, Authentication, Storage (MinIO), Serverless Functions, and Emails (Mailhog).
|
||||
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
@@ -18,8 +17,10 @@ Nhost's command-line interface (CLI) lets you run a complete Nhost development e
|
||||
nhost login
|
||||
```
|
||||
|
||||
<Info>If you used GitHub to register, you will have to first set a password in your [account page](https://app.nhost.io/account).</Info>
|
||||
|
||||
<Info>
|
||||
If you used GitHub to register, you will have to first set a password in your [account
|
||||
page](https://app.nhost.io/account).
|
||||
</Info>
|
||||
|
||||
## Initialize local project
|
||||
|
||||
@@ -38,7 +39,6 @@ nhost init
|
||||
|
||||
`nhost init` creates all the configuration necessary for your project, including database migrations and Hasura metadata.
|
||||
|
||||
|
||||
We can now start your project with `nhost up`.
|
||||
|
||||
```bash
|
||||
@@ -52,13 +52,13 @@ Nhost development environment started.
|
||||
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/local
|
||||
- Hasura: https://local.hasura.nhost.run
|
||||
- GraphQL: https://local.graphql.nhost.run
|
||||
- Auth: https://local.auth.nhost.run
|
||||
- Storage: https://local.storage.nhost.run
|
||||
- Functions: https://local.functions.nhost.run
|
||||
- Dashboard: https://local.dashboard.nhost.run
|
||||
- Mailhog: https://local.mailhog.nhost.run
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
@@ -77,7 +77,7 @@ You can use `nhost down` at any time to tear down your environment.
|
||||
To test that the services are running, let's curl the Auth endpoint and get its version:
|
||||
|
||||
```bash
|
||||
curl https://local.auth.nhost.run/v1/version
|
||||
curl https://local.auth.local.nhost.run/v1/version
|
||||
|
||||
{"version":"v0.20.1"}
|
||||
```
|
||||
@@ -108,8 +108,7 @@ Let's create a new table called `messages`, with the following columns:
|
||||
- author_id (UUID)
|
||||
- created_at (Timestamp)
|
||||
|
||||
There are other ways to generate a migration, here we will use the local Nhost Dashboard running on `https://local.dashboard.nhost.run/local/local`. Please head to the `database` tab and add the columns as follows:
|
||||
|
||||
There are other ways to generate a migration, here we will use the local Nhost Dashboard running on `https://local.dashboard.local.nhost.run/local/local`. Please head to the `database` tab and add the columns as follows:
|
||||
|
||||

|
||||
|
||||
@@ -201,4 +200,3 @@ To check out your deployment, head over to the **Deployments** tab (https://app.
|
||||

|
||||
|
||||
You should now have the same `messages` table (and permissions) on your production instance!
|
||||
|
||||
|
||||
@@ -81,13 +81,13 @@ INFO nothing to apply on database: default
|
||||
Nhost development environment started.
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/postgres
|
||||
- Hasura: https://local.hasura.nhost.run
|
||||
- GraphQL: https://local.graphql.nhost.run
|
||||
- Auth: https://local.auth.nhost.run
|
||||
- Storage: https://local.storage.nhost.run
|
||||
- Functions: https://local.functions.nhost.run
|
||||
- Dashboard: https://local.dashboard.nhost.run
|
||||
- Mailhog: https://local.mailhog.nhost.run
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
Region: (empty)
|
||||
@@ -95,4 +95,3 @@ Run `nhost up` to reload the development environment
|
||||
Run `nhost down` to stop the development environment
|
||||
Run `nhost logs` to watch the logs
|
||||
```
|
||||
|
||||
|
||||
@@ -29,13 +29,13 @@ INFO nothing to apply on database: default
|
||||
Nhost development environment started.
|
||||
URLs:
|
||||
- Postgres: postgres://postgres:postgres@localhost:5432/postgres
|
||||
- Hasura: https://local.hasura.nhost.run
|
||||
- GraphQL: https://local.graphql.nhost.run
|
||||
- Auth: https://local.auth.nhost.run
|
||||
- Storage: https://local.storage.nhost.run
|
||||
- Functions: https://local.functions.nhost.run
|
||||
- Dashboard: https://local.dashboard.nhost.run
|
||||
- Mailhog: https://local.mailhog.nhost.run
|
||||
- Hasura: https://local.hasura.local.nhost.run
|
||||
- GraphQL: https://local.graphql.local.nhost.run
|
||||
- Auth: https://local.auth.local.nhost.run
|
||||
- Storage: https://local.storage.local.nhost.run
|
||||
- Functions: https://local.functions.local.nhost.run
|
||||
- Dashboard: https://local.dashboard.local.nhost.run
|
||||
- Mailhog: https://local.mailhog.local.nhost.run
|
||||
|
||||
SDK Configuration:
|
||||
Subdomain: local
|
||||
@@ -103,5 +103,3 @@ alternativeproject-auth-1 | {"level":"info","message":"Applying metadata..."}
|
||||
alternativeproject-auth-1 | {"level":"info","message":"Metadata applied"}
|
||||
alternativeproject-auth-1 | {"level":"info","message":"Running on port 4000"}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ As mentioned before, you can create a seed from any environment. In this guide,
|
||||
|
||||
```bash
|
||||
$ nhost dev hasura seed create some-initial-data \
|
||||
--endpoint https://local.hasura.nhost.run \
|
||||
--endpoint https://local.hasura.local.nhost.run \
|
||||
--admin-secret nhost-admin-secret \
|
||||
--database-name default \
|
||||
--from-table animals
|
||||
@@ -20,7 +20,12 @@ $ nhost dev hasura seed create some-initial-data \
|
||||
INFO created seed file successfully file=/app/seeds/default/1685692310174_some-initial-data.sql
|
||||
```
|
||||
|
||||
<Info>In the previous command, we instructed the CLI to create a seed named `some-initial-data` while specifying the connection parameters for our local environment. You could also extract data from a cloud project by specifying the correct parameters. Finally, we are only extracting data from the `animals` table, but you could also extract data from any other table or even from all tables.</Info>
|
||||
<Info>
|
||||
In the previous command, we instructed the CLI to create a seed named `some-initial-data` while
|
||||
specifying the connection parameters for our local environment. You could also extract data from a
|
||||
cloud project by specifying the correct parameters. Finally, we are only extracting data from the
|
||||
`animals` table, but you could also extract data from any other table or even from all tables.
|
||||
</Info>
|
||||
|
||||
We can now inspect the file and see its contents:
|
||||
|
||||
@@ -67,7 +72,7 @@ INFO Metadata applied
|
||||
(...) omitted for brevity
|
||||
|
||||
$ nhost dev hasura seed apply \
|
||||
--endpoint https://local.hasura.nhost.run \
|
||||
--endpoint https://local.hasura.local.nhost.run \
|
||||
--admin-secret nhost-admin-secret \
|
||||
--database-name default
|
||||
INFO Help us improve Hasura! The cli collects anonymized usage stats which
|
||||
@@ -76,5 +81,7 @@ visit https://hasura.io/docs/latest/graphql/core/guides/telemetry.html
|
||||
INFO Seeds planted
|
||||
```
|
||||
|
||||
<Info>Seeds are different from migrations because seeds are not automatically applied. If there is data that you want to have in all of your environments, it is best to use a database migration.</Info>
|
||||
|
||||
<Info>
|
||||
Seeds are different from migrations because seeds are not automatically applied. If there is data
|
||||
that you want to have in all of your environments, it is best to use a database migration.
|
||||
</Info>
|
||||
|
||||
@@ -21,9 +21,16 @@ The last thing to notice in the screenshot is that the URLs include information
|
||||
|
||||
Based on the information above, if you want to connect directly to your service from your laptop you can use the URL `http://localhost:5000` while if you want to connect to the Run service from another service (i.e. another Run service or hasura) you can use `http://run-bun-gen:5000`
|
||||
|
||||
<Note>You can also use the environment variable `NHOST_RUN_SERVICE` passing comma-separated values. For instance, the equivalent environment variable for this example would be `NHOST_RUN_SERVICE=../mysvc/nhost-run-service.toml:mysvc,../mysvc/nhost-run-service.toml`</Note>
|
||||
<Note>
|
||||
You can also use the environment variable `NHOST_RUN_SERVICE` passing comma-separated values. For
|
||||
instance, the equivalent environment variable for this example would be
|
||||
`NHOST_RUN_SERVICE=../mysvc/nhost-run-service.toml:mysvc,../mysvc/nhost-run-service.toml`
|
||||
</Note>
|
||||
|
||||
<Warning>The Nhost CLI doesn't build services so make sure you build any image that might be needed for running `nhost run --run-service...`</Warning>
|
||||
<Warning>
|
||||
The Nhost CLI doesn't build services so make sure you build any image that might be needed for
|
||||
running `nhost run --run-service...`
|
||||
</Warning>
|
||||
|
||||
# Quick Development
|
||||
|
||||
@@ -43,7 +50,8 @@ value = 'some-value'
|
||||
[[environment]]
|
||||
name = 'SECRET_KEY'
|
||||
value = '{{ secrets.SECRET_KEY }}'
|
||||
```
|
||||
|
||||
````
|
||||
</Tab>
|
||||
|
||||
<Tab title="overlay">
|
||||
@@ -58,12 +66,13 @@ value = '{{ secrets.SECRET_KEY }}'
|
||||
"path": "/environment/-"
|
||||
},
|
||||
{
|
||||
"value": "https://local.graphql.nhost.run/v1/graphql",
|
||||
"value": "https://local.graphql.local.nhost.run/v1/graphql",
|
||||
"op": "replace",
|
||||
"path": "/environment/0/value"
|
||||
}
|
||||
]
|
||||
```
|
||||
````
|
||||
|
||||
</Tab>
|
||||
<Tab title=".secrets">
|
||||
```toml
|
||||
@@ -77,12 +86,17 @@ We can then generate an `env` file for our service with the folllowing command:
|
||||
```
|
||||
$ nhost run env --config ../mysvc/nhost-run-service.toml --overlay-name local-dev > .env
|
||||
$ cat .env
|
||||
HASURA_GRAPHQL_URL="https://local.graphql.nhost.run/v1/graphql"
|
||||
HASURA_GRAPHQL_URL="https://local.graphql.local.nhost.run/v1/graphql"
|
||||
SOME_CONFIGURATION_PARAMETER="some-value"
|
||||
SECRET_KEY="#asdasd;l;kq23\\n40-0as9d\"\$\\"
|
||||
ENVIRONMENT="dev"
|
||||
```
|
||||
|
||||
<Warning>
|
||||
Keep in mind you may need to use different configuration when attempting to connect to other services in the stack. For instance, in the example above we are using `http://hasura-service:8080/v1/graphql` to connect to hasura in production and in the CLI but when running the service in the host machine using the env file we are using an overlay to change the value to `https://local.graphql.nhost.run/v1/graphql`. Refer to the [network](networking) configuration for more details.
|
||||
Keep in mind you may need to use different configuration when attempting to connect to other
|
||||
services in the stack. For instance, in the example above we are using
|
||||
`http://hasura-service:8080/v1/graphql` to connect to hasura in production and in the CLI but when
|
||||
running the service in the host machine using the env file we are using an overlay to change the
|
||||
value to `https://local.graphql.local.nhost.run/v1/graphql`. Refer to the [network](networking)
|
||||
configuration for more details.
|
||||
</Warning>
|
||||
|
||||
@@ -17,7 +17,8 @@ File metadata is stored in your database in the `files` table in the `storage` s
|
||||
- Create GraphQL relationships between files and your database tables.
|
||||
|
||||
<Warning>
|
||||
Don't modify the database schema, nor GraphQL root fields in any of the tables in the `storage` schema.
|
||||
Don't modify the database schema, nor GraphQL root fields in any of the tables in the `storage`
|
||||
schema.
|
||||
</Warning>
|
||||
|
||||
<Tip>
|
||||
@@ -25,7 +26,7 @@ You're allowed to add and modify the following:
|
||||
|
||||
- GraphQL Relationships
|
||||
- Permissions
|
||||
</Tip>
|
||||
</Tip>
|
||||
|
||||
### Upload File
|
||||
|
||||
@@ -44,7 +45,7 @@ Learn more about [`upload()`](/reference/javascript/storage/upload).
|
||||
<Tab title="HTTP">
|
||||
|
||||
```http
|
||||
POST https://local.storage.nhost.run/v1/files HTTP/1.1
|
||||
POST https://local.storage.local.nhost.run/v1/files HTTP/1.1
|
||||
```
|
||||
|
||||
</Tab>
|
||||
@@ -76,7 +77,7 @@ Learn more about [`getPublicUrl()`](/reference/javascript/storage/get-public-url
|
||||
<Tab title="HTTP">
|
||||
|
||||
```http
|
||||
GET https://local.storage.nhost.run/v1/files/{file_id} HTTP/1.1
|
||||
GET https://local.storage.local.nhost.run/v1/files/{file_id} HTTP/1.1
|
||||
```
|
||||
|
||||
</Tab>
|
||||
@@ -105,7 +106,7 @@ Learn more about [`getPresignedUrl()`](/reference/javascript/storage/get-presign
|
||||
<Tab title="HTTP">
|
||||
|
||||
```http
|
||||
GET https://local.storage.nhost.run/v1/files/{file_id}/presignedurl HTTP/1.1
|
||||
GET https://local.storage.local.nhost.run/v1/files/{file_id}/presignedurl HTTP/1.1
|
||||
```
|
||||
|
||||
</Tab>
|
||||
@@ -128,7 +129,7 @@ Learn more about [`delete()`](/reference/javascript/storage/delete).
|
||||
<Tab title="HTTP">
|
||||
|
||||
```http
|
||||
DELETE https://local.storage.nhost.run/v1/files/{file_id} HTTP/1.1
|
||||
DELETE https://local.storage.local.nhost.run/v1/files/{file_id} HTTP/1.1
|
||||
```
|
||||
|
||||
</Tab>
|
||||
|
||||
@@ -2,7 +2,7 @@ sequenceDiagram
|
||||
user->>+graphite: What is the meaning of life?
|
||||
graphite->>+chatgpt: What is the meaning of life?
|
||||
chatgpt->>-graphite: run `answer_deep_questions`<br/>with {"question": "what is the meaning of life?"}?
|
||||
graphite->>+webhook: POST https://local.functions.nhost.fun/v1/meaning<br/>{"question": "what is the meaning of life?"}
|
||||
graphite->>+webhook: POST https://local.functions.local.nhost.fun/v1/meaning<br/>{"question": "what is the meaning of life?"}
|
||||
webhook->>-graphite: HTTP STATUS OK, Response body: 42
|
||||
graphite->>+chatgpt: 42
|
||||
chatgpt->>-graphite: The answer to the meaning of life is 42...
|
||||
@@ -13,7 +13,7 @@ sequenceDiagram
|
||||
user->>+graphite: recommend a comedy movie...
|
||||
graphite->>+chatgpt: recommend a comedy movie...
|
||||
chatgpt->>-graphite: run `GetMoviesWithScoreHigherThan`<br/>with {"score": 80}
|
||||
graphite->>+graphql: POST https://local.graphql.nhost.fun/v1<br/>query ($score: numeric!) { movies(where: ...
|
||||
graphite->>+graphql: POST https://local.graphql.local.nhost.fun/v1<br/>query ($score: numeric!) { movies(where: ...
|
||||
graphql->>-graphite: { graphql response with movies }
|
||||
graphite->>+chatgpt: { graphql response with movies }
|
||||
chatgpt->>-graphite: Here are a few comedy...
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "2.28.0",
|
||||
"version": "2.28.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "mintlify dev"
|
||||
|
||||
@@ -9,10 +9,9 @@ info:
|
||||
name: LICENSE-TBD
|
||||
url: https://LICENSE-TBD
|
||||
servers:
|
||||
- url: https://local.auth.nhost.run/v1
|
||||
- url: https://local.auth.local.nhost.run/v1
|
||||
description: API Server
|
||||
components:
|
||||
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
scheme: bearer
|
||||
@@ -20,7 +19,6 @@ components:
|
||||
bearerFormat: JWT
|
||||
description: JSON Web Token to authorize requests.
|
||||
schemas:
|
||||
|
||||
SignInWebauthnSessionPayload:
|
||||
type: object
|
||||
properties:
|
||||
@@ -42,13 +40,13 @@ components:
|
||||
rpId:
|
||||
type: string
|
||||
example:
|
||||
challenge: "KOGeoAfC2nrZ_SluhmU5RYYMvBsRDvzghjERGdXbbfQ"
|
||||
challenge: 'KOGeoAfC2nrZ_SluhmU5RYYMvBsRDvzghjERGdXbbfQ'
|
||||
allowCredentials:
|
||||
- id: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
type: "public-key"
|
||||
- id: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
type: 'public-key'
|
||||
timeout: 60000
|
||||
userVerification: "preferred"
|
||||
rpId: "react-apollo.example.nhost.io"
|
||||
userVerification: 'preferred'
|
||||
rpId: 'react-apollo.example.nhost.io'
|
||||
|
||||
ElevateWebauthnSessionPayload:
|
||||
type: object
|
||||
@@ -71,13 +69,13 @@ components:
|
||||
rpId:
|
||||
type: string
|
||||
example:
|
||||
challenge: "KOGeoAfC2nrZ_SluhmU5RYYMvBsRDvzghjERGdXbbfQ"
|
||||
challenge: 'KOGeoAfC2nrZ_SluhmU5RYYMvBsRDvzghjERGdXbbfQ'
|
||||
allowCredentials:
|
||||
- id: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
type: "public-key"
|
||||
- id: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
type: 'public-key'
|
||||
timeout: 60000
|
||||
userVerification: "preferred"
|
||||
rpId: "react-apollo.example.nhost.io"
|
||||
userVerification: 'preferred'
|
||||
rpId: 'react-apollo.example.nhost.io'
|
||||
|
||||
WebauthnSessionPayload:
|
||||
type: object
|
||||
@@ -126,39 +124,39 @@ components:
|
||||
userVerification:
|
||||
type: string
|
||||
example:
|
||||
challenge: "eKdWG60F0lB-wNOviefzn6En0jvdf_GYDyCK2Zuznh8"
|
||||
challenge: 'eKdWG60F0lB-wNOviefzn6En0jvdf_GYDyCK2Zuznh8'
|
||||
rp:
|
||||
name: "hasura-auth"
|
||||
id: "localhost"
|
||||
name: 'hasura-auth'
|
||||
id: 'localhost'
|
||||
user:
|
||||
id: "44b96e0d-18ad-48b9-a517-6537d32223da"
|
||||
name: "john.smith@nhost.io"
|
||||
displayName: "John Smith"
|
||||
id: '44b96e0d-18ad-48b9-a517-6537d32223da'
|
||||
name: 'john.smith@nhost.io'
|
||||
displayName: 'John Smith'
|
||||
pubKeyCredParams:
|
||||
- alg: -7
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -8
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -36
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -37
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -38
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -39
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -257
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -258
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
- alg: -259
|
||||
type: "public-key"
|
||||
type: 'public-key'
|
||||
timeout: 60000
|
||||
attestation: "indirect"
|
||||
attestation: 'indirect'
|
||||
excludeCredentials: []
|
||||
authenticatorSelection:
|
||||
requireResidentKey: false
|
||||
userVerification: "preferred"
|
||||
userVerification: 'preferred'
|
||||
|
||||
CannotSendSmsError:
|
||||
additionalProperties: false
|
||||
@@ -745,17 +743,17 @@ components:
|
||||
type: object
|
||||
example:
|
||||
credential:
|
||||
id: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
rawId: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
id: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
rawId: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
response:
|
||||
attestationObject: "o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViU0RE6Bmg2J-FxNrC8136ZQSeTWKWtdni_Lpfv5XR4bDtdAAAAALraVWanqkAfvZZFYZpVEg0AEMwp7Fr8YLTKzgnl-ngP8qmlAQIDJiABIVgg3XjPRqX1a22te029Du57F1w56XXy3GARb842fEtQlIEiWCC1Jce4J3bN1P-V-78elqwVy0u6MAxEcZwCvkSLqZjkjA"
|
||||
clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSGlwZ0NKLWR0bU1FeWxRN05pRnV4VXE0allNbFIwMDA1V2VmcEJWQm9payIsIm9yaWdpbiI6Imh0dHBzOi8vcmVhY3QtYXBvbGxvLmV4YW1wbGUubmhvc3QuaW8ifQ"
|
||||
type: "public-key"
|
||||
attestationObject: 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViU0RE6Bmg2J-FxNrC8136ZQSeTWKWtdni_Lpfv5XR4bDtdAAAAALraVWanqkAfvZZFYZpVEg0AEMwp7Fr8YLTKzgnl-ngP8qmlAQIDJiABIVgg3XjPRqX1a22te029Du57F1w56XXy3GARb842fEtQlIEiWCC1Jce4J3bN1P-V-78elqwVy0u6MAxEcZwCvkSLqZjkjA'
|
||||
clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiSGlwZ0NKLWR0bU1FeWxRN05pRnV4VXE0allNbFIwMDA1V2VmcEJWQm9payIsIm9yaWdpbiI6Imh0dHBzOi8vcmVhY3QtYXBvbGxvLmV4YW1wbGUubmhvc3QuaW8ifQ'
|
||||
type: 'public-key'
|
||||
clientExtensionResults: {}
|
||||
authenticatorAttachment: "platform"
|
||||
authenticatorAttachment: 'platform'
|
||||
transports:
|
||||
- "internal"
|
||||
- "hybrid"
|
||||
- 'internal'
|
||||
- 'hybrid'
|
||||
options: {}
|
||||
required:
|
||||
- credential
|
||||
@@ -807,18 +805,18 @@ components:
|
||||
authenticatorAttachment:
|
||||
type: string
|
||||
example:
|
||||
email: "nuno@nhost.io"
|
||||
email: 'nuno@nhost.io'
|
||||
credential:
|
||||
id: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
rawId: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
id: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
rawId: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
response:
|
||||
authenticatorData: "0RE6Bmg2J-FxNrC8136ZQSeTWKWtdni_Lpfv5XR4bDsdAAAAAA"
|
||||
clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTkNSSVRVU1pjeFE1ZTFhdUtUcXVlNnA4R0ZacHdxUS1kZzM4bnlWa3NCRSIsIm9yaWdpbiI6Imh0dHBzOi8vcmVhY3QtYXBvbGxvLmV4YW1wbGUubmhvc3QuaW8ifQ"
|
||||
signature: "MEUCIQDRXq3aY-gXWsuYJZhOzqqn6UpoRQfcPdNLP7hpZ7IdvQIgX5rY6TomkYUtqydu-w88fW7KeFm-0oE-5jTdLNHg9zw"
|
||||
userHandle: "8881037a-8495-48ef-8a04-ebbdb69415db"
|
||||
type: "public-key"
|
||||
authenticatorData: '0RE6Bmg2J-FxNrC8136ZQSeTWKWtdni_Lpfv5XR4bDsdAAAAAA'
|
||||
clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTkNSSVRVU1pjeFE1ZTFhdUtUcXVlNnA4R0ZacHdxUS1kZzM4bnlWa3NCRSIsIm9yaWdpbiI6Imh0dHBzOi8vcmVhY3QtYXBvbGxvLmV4YW1wbGUubmhvc3QuaW8ifQ'
|
||||
signature: 'MEUCIQDRXq3aY-gXWsuYJZhOzqqn6UpoRQfcPdNLP7hpZ7IdvQIgX5rY6TomkYUtqydu-w88fW7KeFm-0oE-5jTdLNHg9zw'
|
||||
userHandle: '8881037a-8495-48ef-8a04-ebbdb69415db'
|
||||
type: 'public-key'
|
||||
clientExtensionResults: {}
|
||||
authenticatorAttachment: "platform"
|
||||
authenticatorAttachment: 'platform'
|
||||
required:
|
||||
- email
|
||||
- credential
|
||||
@@ -870,18 +868,18 @@ components:
|
||||
authenticatorAttachment:
|
||||
type: string
|
||||
example:
|
||||
email: "nuno@nhost.io"
|
||||
email: 'nuno@nhost.io'
|
||||
credential:
|
||||
id: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
rawId: "zCnsWvxgtMrOCeX6eA_yqQ"
|
||||
id: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
rawId: 'zCnsWvxgtMrOCeX6eA_yqQ'
|
||||
response:
|
||||
authenticatorData: "0RE6Bmg2J-FxNrC8136ZQSeTWKWtdni_Lpfv5XR4bDsdAAAAAA"
|
||||
clientDataJSON: "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTkNSSVRVU1pjeFE1ZTFhdUtUcXVlNnA4R0ZacHdxUS1kZzM4bnlWa3NCRSIsIm9yaWdpbiI6Imh0dHBzOi8vcmVhY3QtYXBvbGxvLmV4YW1wbGUubmhvc3QuaW8ifQ"
|
||||
signature: "MEUCIQDRXq3aY-gXWsuYJZhOzqqn6UpoRQfcPdNLP7hpZ7IdvQIgX5rY6TomkYUtqydu-w88fW7KeFm-0oE-5jTdLNHg9zw"
|
||||
userHandle: "8881037a-8495-48ef-8a04-ebbdb69415db"
|
||||
type: "public-key"
|
||||
authenticatorData: '0RE6Bmg2J-FxNrC8136ZQSeTWKWtdni_Lpfv5XR4bDsdAAAAAA'
|
||||
clientDataJSON: 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiTkNSSVRVU1pjeFE1ZTFhdUtUcXVlNnA4R0ZacHdxUS1kZzM4bnlWa3NCRSIsIm9yaWdpbiI6Imh0dHBzOi8vcmVhY3QtYXBvbGxvLmV4YW1wbGUubmhvc3QuaW8ifQ'
|
||||
signature: 'MEUCIQDRXq3aY-gXWsuYJZhOzqqn6UpoRQfcPdNLP7hpZ7IdvQIgX5rY6TomkYUtqydu-w88fW7KeFm-0oE-5jTdLNHg9zw'
|
||||
userHandle: '8881037a-8495-48ef-8a04-ebbdb69415db'
|
||||
type: 'public-key'
|
||||
clientExtensionResults: {}
|
||||
authenticatorAttachment: "platform"
|
||||
authenticatorAttachment: 'platform'
|
||||
required:
|
||||
- email
|
||||
- credential
|
||||
@@ -1386,10 +1384,6 @@ components:
|
||||
- expiresAt
|
||||
|
||||
paths:
|
||||
|
||||
|
||||
|
||||
|
||||
/signin/anonymous:
|
||||
post:
|
||||
description: 'Sign In a user anonymously'
|
||||
@@ -1475,7 +1469,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
example: "OK"
|
||||
example: 'OK'
|
||||
description: SMS sent successfully
|
||||
'400':
|
||||
content:
|
||||
@@ -1626,7 +1620,7 @@ paths:
|
||||
|
||||
/signin/webauthn:
|
||||
post:
|
||||
description: ""
|
||||
description: ''
|
||||
parameters: []
|
||||
requestBody:
|
||||
content:
|
||||
@@ -1822,7 +1816,7 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
example: "OK"
|
||||
example: 'OK'
|
||||
description: Successfully signed out
|
||||
'400':
|
||||
content:
|
||||
|
||||
@@ -9,7 +9,7 @@ info:
|
||||
name: LICENSE-TBD
|
||||
url: https://LICENSE-TBD
|
||||
servers:
|
||||
- url: https://local.storage.nhost.run/v1
|
||||
- url: https://local.storage.local.nhost.run/v1
|
||||
description: API Server
|
||||
security:
|
||||
- AdminSecret: []
|
||||
@@ -184,8 +184,8 @@ paths:
|
||||
$ref: '#/components/schemas/Error'
|
||||
/files/{id}:
|
||||
head:
|
||||
summary: "Download File Metadata"
|
||||
description: "Retrieve metadata of a file"
|
||||
summary: 'Download File Metadata'
|
||||
description: 'Retrieve metadata of a file'
|
||||
tags:
|
||||
- storage
|
||||
security:
|
||||
@@ -321,7 +321,7 @@ paths:
|
||||
type: string
|
||||
get:
|
||||
summary: Download File
|
||||
description: "Download a file from storage"
|
||||
description: 'Download a file from storage'
|
||||
tags:
|
||||
- storage
|
||||
security:
|
||||
@@ -498,7 +498,7 @@ paths:
|
||||
$ref: '#/components/schemas/Error'
|
||||
delete:
|
||||
summary: Delete File
|
||||
description: "Delete file from storage"
|
||||
description: 'Delete file from storage'
|
||||
tags:
|
||||
- storage
|
||||
security:
|
||||
@@ -521,7 +521,7 @@ paths:
|
||||
|
||||
/files/{id}/presignedurl:
|
||||
get:
|
||||
summary: Presigned URL
|
||||
summary: Presigned URL
|
||||
description: |
|
||||
Retrieve presigned URL to retrieve the file. Expiration of the URL is
|
||||
determined by bucket configuration
|
||||
@@ -552,8 +552,8 @@ paths:
|
||||
|
||||
/files/{id}/presignedurl/contents:
|
||||
get:
|
||||
summary: "Get File Content"
|
||||
description: "Retrieve content of a file"
|
||||
summary: 'Get File Content'
|
||||
description: 'Retrieve content of a file'
|
||||
tags:
|
||||
- storage
|
||||
security:
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
openapi: post /files/
|
||||
---
|
||||
|
||||
|
||||
<RequestExample>
|
||||
|
||||
```bash Request
|
||||
curl --request POST \
|
||||
--url https://local.storage.nhost.run/v1/files/ \
|
||||
--url https://local.storage.local.nhost.run/v1/files/ \
|
||||
--header 'Authorization: <authorization>' \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form 'file[]=@path-to-file'
|
||||
@@ -16,7 +15,7 @@ curl --request POST \
|
||||
```python Python
|
||||
import requests
|
||||
|
||||
url = "https://local.storage.nhost.run/v1/files/"
|
||||
url = "https://local.storage.local.nhost.run/v1/files/"
|
||||
|
||||
files = {
|
||||
'file': ('<filename>', open('<path-to-file>', 'rb'))
|
||||
@@ -50,7 +49,7 @@ const options = {
|
||||
body: form
|
||||
};
|
||||
|
||||
fetch('https://local.storage.nhost.run/v1/files/', options)
|
||||
fetch('https://local.storage.local.nhost.run/v1/files/', options)
|
||||
.then(response => response.json())
|
||||
.then(response => console.log(response))
|
||||
.catch(err => console.error(err));
|
||||
@@ -60,7 +59,7 @@ fetch('https://local.storage.nhost.run/v1/files/', options)
|
||||
<?php
|
||||
$curl = curl_init();
|
||||
|
||||
$file_path = '<path_to_file>';
|
||||
$file_path = '<path_to_file>';
|
||||
|
||||
$postfields = [
|
||||
"bucket-id" => "<string>",
|
||||
@@ -73,7 +72,7 @@ $postfields = [
|
||||
];
|
||||
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_URL => "https://local.storage.nhost.run/v1/files/",
|
||||
CURLOPT_URL => "https://local.storage.local.nhost.run/v1/files/",
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_ENCODING => "",
|
||||
CURLOPT_MAXREDIRS => 10,
|
||||
@@ -112,7 +111,7 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
url := "https://local.storage.nhost.run/v1/files/"
|
||||
url := "https://local.storage.nhost.local.run/v1/files/"
|
||||
|
||||
var b bytes.Buffer
|
||||
w := multipart.NewWriter(&b)
|
||||
@@ -160,7 +159,7 @@ public class Main {
|
||||
try {
|
||||
File file = new File("<path_to_file>");
|
||||
|
||||
HttpResponse<String> response = Unirest.post("https://local.storage.nhost.run/v1/files/")
|
||||
HttpResponse<String> response = Unirest.post("https://local.storage.local.nhost.run/v1/files/")
|
||||
.header("Authorization", "<authorization>")
|
||||
// Removed Content-Type header, Unirest handles it
|
||||
.field("bucket-id", "<string>")
|
||||
@@ -175,6 +174,5 @@ public class Main {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</RequestExample>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost-examples/cli
|
||||
|
||||
## 0.3.18
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6b8163d]
|
||||
- @nhost/nhost-js@3.2.5
|
||||
|
||||
## 0.3.17
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/cli",
|
||||
"version": "0.3.17",
|
||||
"version": "0.3.18",
|
||||
"main": "src/index.mjs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost-examples/codegen-react-apollo
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react@3.10.1
|
||||
- @nhost/react-apollo@17.0.1
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/codegen-react-apollo",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"codegen": "graphql-codegen",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost-examples/codegen-react-query
|
||||
|
||||
## 0.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 6b8163d: fix(nhost-js) update service URL generation for local environments
|
||||
- @nhost/react@3.10.1
|
||||
|
||||
## 0.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/codegen-react-query",
|
||||
"version": "0.5.0",
|
||||
"version": "0.5.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"codegen": "graphql-codegen",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user