Compare commits
10 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dd4df5170 | ||
|
|
403a45d2cf | ||
|
|
05f063b8e2 | ||
|
|
09f5bed1e8 | ||
|
|
91d5fbba42 | ||
|
|
498363db25 | ||
|
|
8c2779930b | ||
|
|
0aa27a2fd1 | ||
|
|
8656749e5a | ||
|
|
d097eb8feb |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -63,3 +63,5 @@ out/
|
||||
# Nix
|
||||
.envrc
|
||||
.direnv/
|
||||
|
||||
/.vscode/
|
||||
|
||||
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/ .
|
||||
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.18.0",
|
||||
"version": "2.20.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -56,17 +56,11 @@ export default function LogsHeader({
|
||||
const { data, loading: loadingServiceLabelValues } =
|
||||
useGetServiceLabelValuesQuery({
|
||||
variables: { appID: project?.id },
|
||||
skip: !project?.id,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingServiceLabelValues) {
|
||||
const labels = data.getServiceLabelValues ?? [];
|
||||
setServiceLabels(labels.map((l) => ({ label: l, value: l })));
|
||||
}
|
||||
}, [loadingServiceLabelValues, data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loadingServiceLabelValues) {
|
||||
if (!loadingServiceLabelValues && data) {
|
||||
const labels = data.getServiceLabelValues ?? [];
|
||||
|
||||
const labelMappings = {
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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,20 @@ 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,
|
||||
refetch,
|
||||
} = useGetProjectLogsQuery({
|
||||
variables: { appID: project?.id, ...filters },
|
||||
client: clientWithSplit,
|
||||
fetchPolicy: 'cache-and-network',
|
||||
notifyOnNetworkStatusChange: true,
|
||||
skip: !project,
|
||||
});
|
||||
|
||||
const subscribeToMoreLogs = useCallback(
|
||||
() =>
|
||||
@@ -102,15 +109,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,9 +131,7 @@ 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) => {
|
||||
@@ -132,6 +141,8 @@ export default function LogsPage() {
|
||||
[setFilters, refetch],
|
||||
);
|
||||
|
||||
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 }],
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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: [],
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -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();
|
||||
@@ -109,5 +111,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 };
|
||||
|
||||
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!) {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vitest/globals"],
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@/tests/*": ["tests/*"],
|
||||
"@/e2e/*": ["../e2e/*"],
|
||||
"@/components/*": ["components/*"],
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"build:@nhost-examples/nextjs-server-components": "turbo run build --filter=@nhost-examples/nextjs-server-components",
|
||||
"build:@nhost-examples/sveltekit": "turbo run build --filter=@nhost-examples/sveltekit",
|
||||
"dev": "turbo run dev --filter=!@nhost/dashboard --filter=!@nhost/docs --filter=!@nhost-examples/* --filter=!@nhost/docgen",
|
||||
"dev:dashboard": "turbo run dev --filter=@nhost/dashboard",
|
||||
"clean:all": "pnpm clean && rm -rf ./{{packages,examples/**,templates/**}/*,docs,dashboard}/{.nhost,node_modules} node_modules",
|
||||
"clean": "rm -rf ./{{packages,examples/**}/*,docs,dashboard}/{dist,umd,.next,.turbo,coverage}",
|
||||
"ci:version": "changeset version && pnpm install --frozen-lockfile false",
|
||||
|
||||
Reference in New Issue
Block a user