feat (dashboard): improve upgrade project (#3257)
### **PR Type** Enhancement, Tests ___ ### **Description** - Introduced `TransferOrUpgradeProjectDialog` to unify transfer and upgrade dialogs. - Enhanced project upgrade flow with new components and logic. - Added comprehensive tests for the new upgrade and transfer functionalities. - Replaced `TransferProjectDialog` with `TransferOrUpgradeProjectDialog` across the codebase. ___ ### **Changes walkthrough** 📝 <table><thead><tr><th></th><th align="left">Relevant files</th></tr></thead><tbody><tr><td><strong>Miscellaneous</strong></td><td><details><summary>2 files</summary><table> <tr> <td><strong>SelectOrgAndProject.tsx</strong><dd><code>Removed unused import statement.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-7d86c6e5bc51696bf1aa421c920e01a1447699456c37b025bdc407050c7b5613">+0/-1</a> </td> </tr> <tr> <td><strong>OverviewTopBar.tsx</strong><dd><code>Updated import for `UpgradeProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-560ae107ed8e458fa4b4a226b9f5c24e24b042b5f9bcea9317c78e75929faa4b">+1/-1</a> </td> </tr> </table></details></td></tr><tr><td><strong>Enhancement</strong></td><td><details><summary>16 files</summary><table> <tr> <td><strong>UpgradeToProBanner.tsx</strong><dd><code>Updated to use `TransferOrUpgradeProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-f38fc14d24ec6ee22f9a100cc473c641dcdc66284d41d030c456bf505094ed9d">+2/-2</a> </td> </tr> <tr> <td><strong>StripeEmbeddedForm.tsx</strong><dd><code>Wrapped `EmbeddedCheckoutProvider` with a scrollable container.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-d8e63f9bdc9c2c672a4caabd406bf77bec4e4988e716d2b9e101182a863eb495">+10/-8</a> </td> </tr> <tr> <td><strong>TransferProject.tsx</strong><dd><code>Replaced <code>TransferProjectDialog</code> with <code>TransferOrUpgradeProjectDialog</code>.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-bb5ac90e4fcb5841e3fef912beec1b1dbe83b273eea7a9e39fb258ff0361e7e3">+2/-2</a> </td> </tr> <tr> <td><strong>FinishOrgCreationProcess.tsx</strong><dd><code>Refactored to use <code>useFinishOrgCreation</code> hook for dynamic status <br>handling.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-7602855e6aaab1dd3810c866acbedd5b9eb22c271806969eb9a3435f1c76ca8d">+13/-5</a> </td> </tr> <tr> <td><strong>FinishOrgCreation.tsx</strong><dd><code>Simplified `FinishOrgCreation` component logic.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-9e3ccc4f3c0168746e53b68211d07391593712d5d74847861248cfa7da31dd7d">+4/-5</a> </td> </tr> <tr> <td><strong>TransferOrUpgradeProjectDialog.tsx</strong><dd><code>Introduced `TransferOrUpgradeProjectDialog` component.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-06d6ae707f06c0db49a8930a8756195899ece09f08affa44aeadedce4b208948">+105/-0</a> </td> </tr> <tr> <td><strong>TransferProjectDialogContent.tsx</strong><dd><code>Added `TransferProjectDialogContent` for transfer logic.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-3f66f2e8af0175d1c3f9d4940b8dc965fefa18967c8f4977739ac73000708763">+100/-0</a> </td> </tr> <tr> <td><strong>TransferProjectForm.tsx</strong><dd><code>Added `TransferProjectForm` for organization selection and transfer.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-3324c79d8b4d48777467132ba0f13a95d4b0f1a9fbb4df9fd7f67735ac40cbbd">+186/-0</a> </td> </tr> <tr> <td><strong>UpgradeProjectDialogContent.tsx</strong><dd><code>Added `UpgradeProjectDialogContent` for project upgrade flow.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-ced98d2b8b0e83e41fd9bd569a6dd3fb5c4013861d3352628e63abe0c285d2ba">+96/-0</a> </td> </tr> <tr> <td><strong>index.ts</strong><dd><code>Exported `TransferOrUpgradeProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-bd61908ca8ab41f1a88cdcc3bafe4264b1e8120d7f65ff64f158631dd4e65a58">+1/-0</a> </td> </tr> <tr> <td><strong>NotificationsTray.tsx</strong><dd><code>Added router readiness check in `NotificationsTray`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-8b559ee1d3176203e8a4e1588924d57944d09d792117ed578b27cd5401ee5d4f">+3/-1</a> </td> </tr> <tr> <td><strong>useFinishOrgCreation.ts</strong><dd><code>Added router readiness check to `useFinishOrgCreation`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-3b8bf7608ab36d8ab0df895e400f0d2d9e29fad2055b40b33d8d9912a27c99c3">+1/-2</a> </td> </tr> <tr> <td><strong>ApplicationPaused.tsx</strong><dd><code>Replaced <code>TransferProjectDialog</code> with <code>TransferOrUpgradeProjectDialog</code>.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-14afdf5ac20f058c26563a6992a3751f11cf173eec27206001262b5d1b3b979f">+2/-2</a> </td> </tr> <tr> <td><strong>UpgradeNotification.tsx</strong><dd><code>Updated to use `TransferOrUpgradeProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-f712e65a6e88f2731fc5597117f716594311087f8090e3e8f5f76e1a67c95188">+2/-2</a> </td> </tr> <tr> <td><strong>UpgradeProjectDialog.tsx</strong><dd><code>Updated to use `TransferOrUpgradeProjectDialog` for upgrades.</code></dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-7bfab4ad088dbc503c1304f5620e22e02f70602bf14ba6b495969b882b2eb30e">+2/-2</a> </td> </tr> <tr> <td><strong>verify.tsx</strong><dd><code>Refactored to use `FinishOrgCreationProcess` with hooks.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-5fa0ea2519bed6649a8aa98826526945868bd7a925c5ce5edb3fd14e81273947">+1/-5</a> </td> </tr> </table></details></td></tr><tr><td><strong>Tests</strong></td><td><details><summary>2 files</summary><table> <tr> <td><strong>TransferOrUpgradeProjectDialog.test.tsx</strong><dd><code>Added tests for `TransferOrUpgradeProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-1b274953c536fcd901f72765ab134a34641442655988bde5595f63265a9e7ce9">+155/-12</a></td> </tr> <tr> <td><strong>NotificationsTray.test.tsx</strong><dd><code>Added tests for router readiness in `NotificationsTray`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-727f6debec6a102557407e55c56363e0c75486e30a732158f85c81ada892f77c">+39/-4</a> </td> </tr> </table></details></td></tr><tr><td><strong>Cleanup</strong></td><td><details><summary>2 files</summary><table> <tr> <td><strong>TransferProjectDialog.tsx</strong><dd><code>Removed deprecated `TransferProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-b68d4641a67e07a8bf8c14e1f705059c564e1bca53e591783581af27a488d86e">+0/-306</a> </td> </tr> <tr> <td><strong>index.ts</strong><dd><code>Removed export for deprecated `TransferProjectDialog`.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-ed023a2c08c77e3693789305cf9b9f2cd871090acf7b0775c7d7434903710c42">+0/-1</a> </td> </tr> </table></details></td></tr><tr><td><strong>Documentation</strong></td><td><details><summary>1 files</summary><table> <tr> <td><strong>tame-planes-sleep.md</strong><dd><code>Added changeset for project upgrade dialog improvements.</code> </dd></td> <td><a href="https://github.com/nhost/nhost/pull/3257/files#diff-c83c4e28de9a00c1ee2cb4ad9867d2c42415c01c80e990205c351e6f5c8a6f83">+5/-0</a> </td> </tr> </table></details></td></tr></tr></tbody></table> ___ > <details> <summary> Need help?</summary><li>Type <code>/help how to ...</code> in the comments thread for any questions about PR-Agent usage.</li><li>Check out the <a href="https://qodo-merge-docs.qodo.ai/usage-guide/">documentation</a> for more information.</li></details>
This commit is contained in:
5
.changeset/tame-planes-sleep.md
Normal file
5
.changeset/tame-planes-sleep.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@nhost/dashboard': patch
|
||||
---
|
||||
|
||||
feat (dashboard): improve Upgrade project dialog
|
||||
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -28,6 +28,7 @@ env:
|
||||
NHOST_TEST_USER_EMAIL: ${{ secrets.NHOST_TEST_USER_EMAIL }}
|
||||
NHOST_TEST_USER_PASSWORD: ${{ secrets.NHOST_TEST_USER_PASSWORD }}
|
||||
NHOST_TEST_PROJECT_ADMIN_SECRET: ${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}
|
||||
NHOST_TEST_FREE_USER_EMAILS: ${{ secrets.NHOST_TEST_FREE_USER_EMAILS }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -169,6 +170,10 @@ jobs:
|
||||
- name: Set Dashboard Preview URL
|
||||
if: steps.fetch-dashboard-preview-url.outputs.preview_url != ''
|
||||
run: echo "NHOST_TEST_DASHBOARD_URL=https://${{ steps.fetch-dashboard-preview-url.outputs.preview_url }}" >> $GITHUB_ENV
|
||||
- name: Run Upgrade project Dashboard e2e tests
|
||||
if: matrix.package.path == 'dashboard'
|
||||
timeout-minutes: 10
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e:upgrade-project
|
||||
# * Run the `ci` script of the current package of the matrix. Dependencies build is cached by Turborepo
|
||||
- name: Run e2e tests
|
||||
timeout-minutes: 20
|
||||
@@ -177,8 +182,7 @@ jobs:
|
||||
- name: Run Local Dashboard e2e tests
|
||||
if: matrix.package.path == 'dashboard'
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
pnpm --filter="${{ matrix.package.name }}" run e2e-local
|
||||
run: pnpm --filter="${{ matrix.package.name }}" run e2e:local
|
||||
|
||||
- name: Stop Nhost CLI
|
||||
if: matrix.package.path == 'dashboard'
|
||||
|
||||
@@ -40,3 +40,7 @@ export const TEST_USER_EMAIL = process.env.NHOST_TEST_USER_EMAIL;
|
||||
export const TEST_USER_PASSWORD = process.env.NHOST_TEST_USER_PASSWORD;
|
||||
|
||||
export const TEST_PERSONAL_ORG_SLUG = process.env.NHOST_TEST_PERSONAL_ORG_SLUG;
|
||||
|
||||
const freeUserEmails = process.env.NHOST_TEST_FREE_USER_EMAILS;
|
||||
|
||||
export const TEST_FREE_USER_EMAILS = JSON.parse(freeUserEmails);
|
||||
|
||||
144
dashboard/e2e/upgrade-project/upgrade-project.test.ts
Normal file
144
dashboard/e2e/upgrade-project/upgrade-project.test.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { expect, test } from '@/e2e/fixtures/auth-hook';
|
||||
import {
|
||||
getCardExpiration,
|
||||
getFreeUserStarterOrgSlug,
|
||||
getNewOrgSlug,
|
||||
getNewProjectName,
|
||||
getNewProjectSlug,
|
||||
getOrgSlugFromUrl,
|
||||
getProjectSlugFromUrl,
|
||||
gotoUrl,
|
||||
loginWithFreeUser,
|
||||
setNewOrgSlug,
|
||||
setNewProjectName,
|
||||
setNewProjectSlug,
|
||||
} from '@/e2e/utils';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
let page: Page;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
page = await browser.newPage();
|
||||
await loginWithFreeUser(page);
|
||||
});
|
||||
|
||||
test('should create a new project', async () => {
|
||||
await await gotoUrl(
|
||||
page,
|
||||
`/orgs/${getFreeUserStarterOrgSlug()}/projects/new`,
|
||||
);
|
||||
const projectName = faker.lorem.words(3);
|
||||
|
||||
await page.getByLabel('Project Name').fill(projectName);
|
||||
await page.getByText('Create Project').click();
|
||||
|
||||
expect(await page.getByText('Creating the project...')).toBeVisible();
|
||||
expect(await page.getByText('Internal info')).toBeVisible();
|
||||
|
||||
await page.waitForSelector('button:has-text("Upgrade project")', {
|
||||
timeout: 120000,
|
||||
});
|
||||
|
||||
const newProjectSlug = getProjectSlugFromUrl(await page.url());
|
||||
setNewProjectSlug(newProjectSlug);
|
||||
setNewProjectName(projectName);
|
||||
});
|
||||
|
||||
test('should upgrade the project', async () => {
|
||||
await gotoUrl(
|
||||
page,
|
||||
`/orgs/${getFreeUserStarterOrgSlug()}/projects/${getNewProjectSlug()}`,
|
||||
);
|
||||
const upgradeProject = await page.getByText('Upgrade project');
|
||||
expect(upgradeProject).toBeVisible();
|
||||
|
||||
await upgradeProject.click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Upgrade project")');
|
||||
|
||||
await page.getByRole('button', { name: 'Continue' }).click();
|
||||
await page.waitForSelector('h2:has-text("New Organization")');
|
||||
|
||||
const newOrgName = faker.lorem.words(3);
|
||||
await page.getByLabel('Organization Name').fill(newOrgName);
|
||||
|
||||
await page.getByText('Create organization').click();
|
||||
await page.waitForSelector('button:has-text("Create organization")', {
|
||||
state: 'hidden',
|
||||
});
|
||||
const stripeFrame = await page
|
||||
.frameLocator('iframe[name="embedded-checkout"]')
|
||||
.first();
|
||||
await stripeFrame.getByText('Subscribe to Nhost');
|
||||
await stripeFrame.getByLabel('Email').fill(faker.internet.email());
|
||||
|
||||
await stripeFrame
|
||||
.getByPlaceholder('1234 1234 1234 1234')
|
||||
.fill('4242424242424242');
|
||||
|
||||
await stripeFrame.getByPlaceholder('MM / YY').fill(getCardExpiration());
|
||||
await stripeFrame.getByPlaceholder('CVC').fill('123');
|
||||
await stripeFrame
|
||||
.getByPlaceholder('Full name on card')
|
||||
.fill('EndyTo EndyTest');
|
||||
await stripeFrame.locator('#billingCountry').scrollIntoViewIfNeeded();
|
||||
// Need to comment out for local testing START
|
||||
await stripeFrame.getByPlaceholder('Address', { exact: true }).click();
|
||||
await stripeFrame.locator('span:has-text("Enter address manually")');
|
||||
await stripeFrame.getByText('Enter address manually').click();
|
||||
await stripeFrame
|
||||
.getByPlaceholder('Address line 1', { exact: true })
|
||||
.fill('123 Main Street');
|
||||
await stripeFrame
|
||||
.getByPlaceholder('City', { exact: true })
|
||||
.fill('Springfield');
|
||||
await stripeFrame.getByPlaceholder('ZIP', { exact: true }).fill('62701');
|
||||
// local Comment end
|
||||
await stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
.scrollIntoViewIfNeeded();
|
||||
await stripeFrame
|
||||
.getByTestId('hosted-payment-submit-button')
|
||||
.click({ force: true });
|
||||
|
||||
await page.waitForSelector('h2:has-text("Upgrade project")');
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Organization created successfully.")',
|
||||
);
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Project has been upgraded successfully!")',
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Create project' });
|
||||
|
||||
await page.waitForSelector(`div:has-text("${newOrgName}")`);
|
||||
await page.waitForSelector(`p:has-text("${getNewProjectName()}")`);
|
||||
|
||||
setNewOrgSlug(getOrgSlugFromUrl(await page.url()));
|
||||
});
|
||||
|
||||
test('should delete the new organization', async () => {
|
||||
await gotoUrl(page, `/orgs/${getNewOrgSlug()}/projects`);
|
||||
await page.getByRole('link', { name: 'Settings' }).click();
|
||||
|
||||
await page.waitForSelector('h3:has-text("Delete Organization")');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForSelector('h2:has-text("Delete Organization")');
|
||||
expect(await page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
|
||||
await page.getByLabel("I'm sure I want to delete this Organization").click();
|
||||
expect(await page.getByTestId('deleteOrgButton')).toBeDisabled();
|
||||
await page.getByLabel('I understand this action cannot be undone').click();
|
||||
expect(await page.getByTestId('deleteOrgButton')).not.toBeDisabled();
|
||||
|
||||
await page.getByTestId('deleteOrgButton').click();
|
||||
|
||||
await page.waitForSelector('div:has-text("Deleting the organization")');
|
||||
await page.waitForSelector(
|
||||
'div:has-text("Successfully deleted the organization")',
|
||||
);
|
||||
|
||||
await page.waitForSelector(`div:has-text("Personal Organization")`);
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
import { TEST_ORGANIZATION_SLUG, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import {
|
||||
TEST_FREE_USER_EMAILS,
|
||||
TEST_ORGANIZATION_SLUG,
|
||||
TEST_PROJECT_SUBDOMAIN,
|
||||
TEST_USER_PASSWORD,
|
||||
} from '@/e2e/env';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { type Page, expect } from '@playwright/test';
|
||||
import { add, format } from 'date-fns-v4';
|
||||
|
||||
/**
|
||||
* Open a project by navigating to the project's overview page.
|
||||
@@ -213,8 +219,96 @@ export async function clickPermissionButton({
|
||||
.click();
|
||||
}
|
||||
|
||||
export async function gotoAuthURL(page) {
|
||||
export async function gotoAuthURL(page: Page) {
|
||||
const authUrl = `/orgs/${TEST_ORGANIZATION_SLUG}/projects/${TEST_PROJECT_SUBDOMAIN}/users`;
|
||||
await page.goto(authUrl);
|
||||
await page.waitForURL(authUrl, { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
export async function gotoUrl(page: Page, url: string) {
|
||||
await page.url;
|
||||
await page.goto(url);
|
||||
await page.waitForURL(url, { waitUntil: 'networkidle' });
|
||||
}
|
||||
|
||||
let newOrgSlug: string;
|
||||
|
||||
export function getNewOrgSlug() {
|
||||
return newOrgSlug;
|
||||
}
|
||||
|
||||
export function setNewOrgSlug(slug: string) {
|
||||
newOrgSlug = slug;
|
||||
}
|
||||
|
||||
let freeUserStarterOrgSlug: string;
|
||||
|
||||
export function getFreeUserStarterOrgSlug() {
|
||||
return freeUserStarterOrgSlug;
|
||||
}
|
||||
|
||||
export function setFreeUserStarterOrgSlug(slug: string) {
|
||||
freeUserStarterOrgSlug = slug;
|
||||
}
|
||||
|
||||
let newProjectSlug: string;
|
||||
|
||||
export function getNewProjectSlug() {
|
||||
return newProjectSlug;
|
||||
}
|
||||
|
||||
export function setNewProjectSlug(slug: string) {
|
||||
newProjectSlug = slug;
|
||||
}
|
||||
|
||||
export function getProjectSlugFromUrl(url: string) {
|
||||
const [, projectSlug] = url.split('/projects/');
|
||||
|
||||
return projectSlug;
|
||||
}
|
||||
|
||||
export function getOrgSlugFromUrl(url: string) {
|
||||
const orgSlug = url.split('/orgs/')[1].replace('/projects', '');
|
||||
return orgSlug;
|
||||
}
|
||||
|
||||
export function getCardExpiration() {
|
||||
const now = add(new Date(), { years: 3 });
|
||||
return format(now, 'MMyy');
|
||||
}
|
||||
|
||||
let newProjectName: string;
|
||||
|
||||
export function getNewProjectName() {
|
||||
return newProjectName;
|
||||
}
|
||||
|
||||
export function setNewProjectName(name: string) {
|
||||
newProjectName = name;
|
||||
}
|
||||
|
||||
function getRandomUserIndex(): number {
|
||||
return Math.floor(Math.random() * 5);
|
||||
}
|
||||
|
||||
export async function loginWithFreeUser(page: Page) {
|
||||
const userIndex = getRandomUserIndex();
|
||||
|
||||
const freeUserEmail = TEST_FREE_USER_EMAILS[userIndex];
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Selected userIndex: ${userIndex}`);
|
||||
await page.goto('/');
|
||||
await page.waitForURL('/signin');
|
||||
await page.getByRole('link', { name: /continue with email/i }).click();
|
||||
|
||||
await page.waitForURL('/signin/email');
|
||||
await page.getByLabel('Email').fill(freeUserEmail);
|
||||
await page.getByLabel('Password').fill(TEST_USER_PASSWORD);
|
||||
await page.getByRole('button', { name: /sign in/i }).click();
|
||||
expect(
|
||||
await page.getByRole('button', { name: 'Create project' }),
|
||||
).not.toBeVisible();
|
||||
await page.waitForSelector('h2:has-text("Welcome to")', { timeout: 20000 });
|
||||
setFreeUserStarterOrgSlug(getOrgSlugFromUrl(await page.url()));
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
"storybook": "start-storybook -p 6006 -s public",
|
||||
"build-storybook": "build-storybook",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
|
||||
"e2e-local": "pnpm install-browsers && pnpm playwright test --config=playwright.local.config.ts"
|
||||
"e2e:tests": "pnpm install-browsers && pnpm playwright test --config=playwright.config.ts",
|
||||
"e2e": "pnpm e2e:tests --project=main",
|
||||
"e2e:local": "pnpm e2e:tests --project=local",
|
||||
"e2e:upgrade-project": "pnpm e2e:tests --project=upgrade-project"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.9.9",
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
trace: 'retain-on-failure',
|
||||
baseURL: process.env.NHOST_TEST_DASHBOARD_URL,
|
||||
launchOptions: {
|
||||
slowMo: 500,
|
||||
@@ -34,13 +34,28 @@ export default defineConfig({
|
||||
testMatch: ['**/teardown/*.teardown.ts'],
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
name: 'main',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'e2e/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
grepInvert: [/Local Dashboard CLI e2e tests/],
|
||||
testIgnore: ['upgrade-project.test.ts', 'cli-local-dashboard.test.ts'],
|
||||
},
|
||||
{
|
||||
name: 'local',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
baseURL: '', // Local dashboard URL
|
||||
},
|
||||
testMatch: 'cli-local-dashboard.test.ts',
|
||||
},
|
||||
{
|
||||
name: 'upgrade-project',
|
||||
testMatch: 'upgrade-project.test.ts',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
trace: 'on-first-retry',
|
||||
baseURL: '', // Local dashboard URL
|
||||
launchOptions: {
|
||||
slowMo: 500,
|
||||
},
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
testMatch: ['**/e2e/cli-local-dashboard/**'],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -82,7 +82,7 @@ describe('DateTimePicker', () => {
|
||||
const secondsInput = await screen.getByLabelText('Seconds');
|
||||
await user.type(secondsInput, '13');
|
||||
|
||||
user.click(await screen.getByRole('button', { name: 'Select' }));
|
||||
await user.click(await screen.getByRole('button', { name: 'Select' }));
|
||||
|
||||
await waitFor(async () =>
|
||||
expect(
|
||||
|
||||
@@ -7,7 +7,6 @@ import { List } from '@/components/ui/v2/List';
|
||||
import { ListItem } from '@/components/ui/v2/ListItem';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import {} from '@/utils/__generated__/graphql';
|
||||
import { Divider } from '@mui/material';
|
||||
import debounce from 'lodash.debounce';
|
||||
import Image from 'next/image';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Box } from '@/components/ui/v2/Box';
|
||||
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 { TransferOrUpgradeProjectDialog } from '@/features/orgs/components/common/TransferOrUpgradeProjectDialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
@@ -51,7 +51,7 @@ export default function UpgradeToProBanner({
|
||||
|
||||
<div className="flex flex-col gap-2 space-y-2 lg:flex-row lg:items-center lg:space-x-2 lg:space-y-0">
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
<TransferOrUpgradeProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
/>
|
||||
|
||||
@@ -14,13 +14,15 @@ export default function StripeEmbeddedForm({
|
||||
clientSecret: string;
|
||||
}) {
|
||||
return (
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
<div className="h-[80vh] overflow-y-scroll">
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
}}
|
||||
>
|
||||
<EmbeddedCheckout />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { SettingsContainer } from '@/components/layout/SettingsContainer';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { TransferOrUpgradeProjectDialog } from '@/features/orgs/components/common/TransferOrUpgradeProjectDialog';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function TransferProject() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<TransferProjectDialog open={open} setOpen={setOpen} />
|
||||
<TransferOrUpgradeProjectDialog open={open} setOpen={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { DialogDescription } from '@/components/ui/v3/dialog';
|
||||
import { useFinishOrgCreation } from '@/features/orgs/hooks/useFinishOrgCreation';
|
||||
import { type FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||
import { CheckoutStatus } from '@/utils/__generated__/graphql';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
status: CheckoutStatus | null;
|
||||
onCompleted: FinishOrgCreationOnCompletedCb;
|
||||
onError?: () => void;
|
||||
successMessage: string;
|
||||
loadingMessage: string;
|
||||
errorMessage: string;
|
||||
pendingMessage: string;
|
||||
withDialogDescription?: boolean;
|
||||
}
|
||||
|
||||
function FinishOrgCreationProcess({
|
||||
loading,
|
||||
status,
|
||||
onCompleted,
|
||||
onError,
|
||||
successMessage,
|
||||
loadingMessage,
|
||||
errorMessage,
|
||||
pendingMessage,
|
||||
withDialogDescription,
|
||||
}: Props) {
|
||||
const [loading, status] = useFinishOrgCreation({ onCompleted, onError });
|
||||
let message: string | undefined;
|
||||
|
||||
switch (status) {
|
||||
@@ -38,13 +44,15 @@ function FinishOrgCreationProcess({
|
||||
message = loadingMessage;
|
||||
}
|
||||
|
||||
const Component = withDialogDescription ? DialogDescription : 'span';
|
||||
|
||||
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>
|
||||
<Component>{message}</Component>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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 {
|
||||
@@ -8,15 +7,15 @@ interface Props {
|
||||
}
|
||||
|
||||
function FinishOrgCreation({ onCompleted, onError }: Props) {
|
||||
const [loading, status] = useFinishOrgCreation({ onCompleted, onError });
|
||||
return (
|
||||
<FinishOrgCreationProcess
|
||||
loading={loading}
|
||||
status={status}
|
||||
loadingMessage="Processing new organization request"
|
||||
onCompleted={onCompleted}
|
||||
onError={onError}
|
||||
loadingMessage="Creating new organization"
|
||||
successMessage="Organization created successfully."
|
||||
pendingMessage="Organization creation is pending..."
|
||||
errorMessage="Error occurred while creating the organization. Please try again."
|
||||
withDialogDescription
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,12 +22,17 @@ import {
|
||||
import { setupServer } from 'msw/node';
|
||||
import { useState } from 'react';
|
||||
import { afterAll, beforeAll, vi } from 'vitest';
|
||||
import TransferProjectDialog from './TransferProjectDialog';
|
||||
import TransferorUpgradeProjectDialog from './TransferOrUpgradeProjectDialog';
|
||||
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
const mocks = vi.hoisted(() => ({
|
||||
useRouter: vi.fn(),
|
||||
useOrgs: vi.fn(),
|
||||
push: vi.fn(),
|
||||
}));
|
||||
|
||||
mockPointerEvent();
|
||||
|
||||
@@ -44,7 +49,7 @@ const getUseRouterObject = (session_id?: string) => ({
|
||||
appSubdomain: 'test-project',
|
||||
session_id,
|
||||
},
|
||||
push: vi.fn(),
|
||||
push: mocks.push,
|
||||
replace: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
back: vi.fn(),
|
||||
@@ -58,11 +63,6 @@ const getUseRouterObject = (session_id?: string) => ({
|
||||
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',
|
||||
@@ -78,17 +78,42 @@ const postOrganizationRequestResolver = createGraphqlMockResolver(
|
||||
'mutation',
|
||||
);
|
||||
|
||||
const billingTransferAppRequestResolver = createGraphqlMockResolver(
|
||||
'billingTransferApp',
|
||||
'mutation',
|
||||
);
|
||||
|
||||
vi.mock('next/router', () => ({
|
||||
useRouter: mocks.useRouter,
|
||||
}));
|
||||
|
||||
async function asyncFireEvent(element: Document | Element | Window | Node) {
|
||||
await waitFor(() => {
|
||||
fireEvent(
|
||||
element,
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function DialogWrapper({
|
||||
defaultOpen = true,
|
||||
isUpgrade = false,
|
||||
}: {
|
||||
defaultOpen?: boolean;
|
||||
isUpgrade?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
return <TransferProjectDialog open={open} setOpen={setOpen} />;
|
||||
return (
|
||||
<TransferorUpgradeProjectDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
isUpgrade={isUpgrade}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const server = setupServer(tokenQuery);
|
||||
@@ -102,11 +127,12 @@ beforeAll(() => {
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
mocks.useRouter.mockRestore();
|
||||
mocks.push.mockRestore();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('opens create org dialog when selecting "create new org" and closes transfer dialog', async () => {
|
||||
@@ -141,13 +167,7 @@ test('opens create org dialog when selecting "create new org" and closes transfe
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
expect(submitButton).toHaveTextContent('Continue');
|
||||
|
||||
fireEvent(
|
||||
submitButton,
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
asyncFireEvent(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeInTheDocument();
|
||||
@@ -156,13 +176,48 @@ test('opens create org dialog when selecting "create new org" and closes transfe
|
||||
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,
|
||||
}),
|
||||
);
|
||||
asyncFireEvent(closeButton);
|
||||
await waitFor(() => {
|
||||
expect(newOrgTitle).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const submitButtonAfterClosingNewOrgDialog =
|
||||
await screen.findByText('Continue');
|
||||
await waitFor(() => {
|
||||
expect(submitButtonAfterClosingNewOrgDialog).toHaveTextContent('Continue');
|
||||
});
|
||||
});
|
||||
test('when upgrading a project by clicking on the Continue button the create new org modal is opened and the initial dialog is closed', async () => {
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject());
|
||||
|
||||
server.use(getProjectQuery);
|
||||
server.use(getOrganization);
|
||||
mocks.useOrgs.mockImplementation(() => ({
|
||||
orgs: mockOrganizations,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
}));
|
||||
server.use(prefetchNewAppQuery);
|
||||
|
||||
render(<DialogWrapper isUpgrade />);
|
||||
|
||||
expect(await screen.findByText('Upgrade project')).toBeInTheDocument();
|
||||
|
||||
const submitButton = await screen.findByText('Continue');
|
||||
expect(submitButton).toHaveTextContent('Continue');
|
||||
|
||||
asyncFireEvent(submitButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(submitButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
const newOrgTitle = await screen.findByText('New Organization');
|
||||
expect(newOrgTitle).toBeInTheDocument();
|
||||
const closeButton = await screen.findByText('Close');
|
||||
|
||||
asyncFireEvent(closeButton);
|
||||
await waitFor(() => {
|
||||
expect(newOrgTitle).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -182,7 +237,9 @@ test(`transfer dialog opens automatically when there is a session_id and selects
|
||||
orgs: mockOrganizations,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
refetch: async () => ({
|
||||
data: { organizations: mockOrganizationsWithNewOrg },
|
||||
}),
|
||||
}));
|
||||
server.use(prefetchNewAppQuery);
|
||||
server.use(postOrganizationRequestResolver.handler);
|
||||
@@ -196,13 +253,7 @@ test(`transfer dialog opens automatically when there is a session_id and selects
|
||||
|
||||
const closeButton = await screen.findByText('Close');
|
||||
|
||||
fireEvent(
|
||||
closeButton,
|
||||
new MouseEvent('click', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
asyncFireEvent(closeButton);
|
||||
|
||||
await waitFor(() => {});
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
@@ -220,15 +271,84 @@ test(`transfer dialog opens automatically when there is a session_id and selects
|
||||
orgs: mockOrganizationsWithNewOrg,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: vi.fn(),
|
||||
refetch: async () => ({
|
||||
data: { organizations: mockOrganizationsWithNewOrg },
|
||||
}),
|
||||
}));
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(
|
||||
await screen.queryByRole('combobox', {
|
||||
name: /Organization/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const organizationCombobox = await screen.findByRole('combobox', {
|
||||
name: /Organization/i,
|
||||
});
|
||||
|
||||
expect(organizationCombobox).toHaveTextContent(newOrg.name);
|
||||
|
||||
const submitButton = await screen.findByText('Transfer');
|
||||
expect(submitButton).not.toBeDisabled();
|
||||
});
|
||||
test(`upgrade project dialog opens automatically when there is a session_id and transfers the project to ${newOrg.name}`, async () => {
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject('session_id'));
|
||||
server.use(getProjectQuery);
|
||||
server.use(getOrganization);
|
||||
|
||||
mocks.useOrgs.mockImplementation(() => ({
|
||||
orgs: mockOrganizations,
|
||||
currentOrg: mockOrganization,
|
||||
loading: false,
|
||||
refetch: async () => ({
|
||||
data: { organizations: mockOrganizationsWithNewOrg },
|
||||
}),
|
||||
}));
|
||||
server.use(prefetchNewAppQuery);
|
||||
server.use(postOrganizationRequestResolver.handler);
|
||||
server.use(billingTransferAppRequestResolver.handler);
|
||||
|
||||
render(<DialogWrapper defaultOpen={false} isUpgrade />);
|
||||
const processingNewOrgText = await screen.findByText(
|
||||
'Creating new organization',
|
||||
);
|
||||
|
||||
expect(processingNewOrgText).toBeInTheDocument();
|
||||
|
||||
const closeButton = await screen.findByText('Close');
|
||||
|
||||
asyncFireEvent(closeButton);
|
||||
|
||||
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: async () => ({
|
||||
data: { organizations: mockOrganizationsWithNewOrg },
|
||||
}),
|
||||
}));
|
||||
await waitFor(async () => {
|
||||
expect(await screen.findByText('Upgrading project...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
billingTransferAppRequestResolver.resolve({
|
||||
billingTransferApp: true,
|
||||
});
|
||||
|
||||
await waitFor(async () => {});
|
||||
|
||||
expect(mocks.push).toHaveBeenCalledWith('/orgs/new-org/projects');
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { Dialog, DialogContent } from '@/components/ui/v3/dialog';
|
||||
import CreateOrgDialog from '@/features/orgs/components/CreateOrgFormDialog/CreateOrgFormDialog';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import TransferProjectDialogContent from './TransferProjectDialogContent';
|
||||
import UpgradeProjectDialogContent from './UpgradeProjectDialogContent';
|
||||
|
||||
interface TransferProjectDialogProps {
|
||||
open: boolean;
|
||||
setOpen: (value: boolean) => void;
|
||||
isUpgrade?: boolean;
|
||||
}
|
||||
|
||||
export default function TransferOrUpgradeProjectDialog({
|
||||
open,
|
||||
setOpen,
|
||||
isUpgrade,
|
||||
}: TransferProjectDialogProps) {
|
||||
const { asPath, query, isReady: isRouterReady } = useRouter();
|
||||
const { session_id } = query;
|
||||
const { loading: projectLoading } = useProject();
|
||||
const { loading: orgsLoading } = useOrgs();
|
||||
|
||||
const [showCreateOrgModal, setShowCreateOrgModal] = useState(false);
|
||||
const [preventClose, setPreventClose] = useState(false);
|
||||
const [selectedOrgId, setSelectedOrgId] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (session_id && isRouterReady) {
|
||||
setOpen(true);
|
||||
setPreventClose(true);
|
||||
}
|
||||
}, [session_id, setOpen, isRouterReady]);
|
||||
|
||||
const path = asPath.split('?')[0];
|
||||
const redirectUrl = `${window.location.origin}${path}`;
|
||||
|
||||
const handleCreateDialogOpenStateChange = (newState: boolean) => {
|
||||
setShowCreateOrgModal(newState);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const handleFinishOrgCreationCompleted = useCallback(async () => {
|
||||
setPreventClose(false);
|
||||
}, []);
|
||||
|
||||
const handleTransferProjectDialogOpenChange = (newValue: boolean) => {
|
||||
if (preventClose) {
|
||||
return;
|
||||
}
|
||||
if (!newValue) {
|
||||
setSelectedOrgId(undefined);
|
||||
}
|
||||
|
||||
setOpen(newValue);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleCreateNewOrg = () => {
|
||||
setShowCreateOrgModal(true);
|
||||
setOpen(false);
|
||||
};
|
||||
const handleOnCreateOrgError = useCallback(() => setPreventClose(false), []);
|
||||
|
||||
if (projectLoading || orgsLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleTransferProjectDialogOpenChange}>
|
||||
<DialogContent className="z-[9999] text-foreground sm:max-w-xl">
|
||||
{isUpgrade ? (
|
||||
<UpgradeProjectDialogContent
|
||||
onCancel={handleCancel}
|
||||
onCreateNewOrg={handleCreateNewOrg}
|
||||
onCreateOrgError={handleOnCreateOrgError}
|
||||
/>
|
||||
) : (
|
||||
<TransferProjectDialogContent
|
||||
onFinishOrgCreationCompleted={handleFinishOrgCreationCompleted}
|
||||
onFinishOrgError={() => setPreventClose(false)}
|
||||
onCreateNewOrg={handleCreateNewOrg}
|
||||
onCancel={handleCancel}
|
||||
selectedOrganizationId={selectedOrgId}
|
||||
onOrganizationChange={setSelectedOrgId}
|
||||
/>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<CreateOrgDialog
|
||||
hideNewOrgButton
|
||||
isOpen={showCreateOrgModal}
|
||||
onOpenStateChange={handleCreateDialogOpenStateChange}
|
||||
redirectUrl={redirectUrl}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import { type FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import FinishOrgCreation from './FinishOrgCreation';
|
||||
import TransferProjectForm, {
|
||||
type TransferProjectFormProps,
|
||||
} from './TransferProjectForm';
|
||||
|
||||
interface Props extends TransferProjectFormProps {
|
||||
onFinishOrgCreationCompleted: () => void;
|
||||
onFinishOrgError: () => void;
|
||||
}
|
||||
|
||||
function TransferProjectDialogContent({
|
||||
onCreateNewOrg,
|
||||
onCancel,
|
||||
onFinishOrgCreationCompleted,
|
||||
onFinishOrgError,
|
||||
selectedOrganizationId,
|
||||
onOrganizationChange,
|
||||
}: Props) {
|
||||
const { query, replace, pathname } = useRouter();
|
||||
const { session_id, ...remainingQuery } = query;
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const [showContent, setShowContent] = useState(true);
|
||||
|
||||
const removeSessionIdFromQuery = useCallback(() => {
|
||||
replace({ pathname, query: remainingQuery }, undefined, {
|
||||
shallow: true,
|
||||
});
|
||||
}, [replace, remainingQuery, pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNotEmptyValue(session_id)) {
|
||||
setShowContent(false);
|
||||
}
|
||||
}, [session_id]);
|
||||
|
||||
const handleOnCompleted: FinishOrgCreationOnCompletedCb = useCallback(
|
||||
async ({ Slug }) => {
|
||||
removeSessionIdFromQuery();
|
||||
const {
|
||||
data: { organizations },
|
||||
} = await refetchOrgs();
|
||||
|
||||
const newOrg = organizations.find((org) => org.slug === Slug);
|
||||
|
||||
setShowContent(true);
|
||||
onOrganizationChange(newOrg.id);
|
||||
onFinishOrgCreationCompleted();
|
||||
},
|
||||
[
|
||||
onFinishOrgCreationCompleted,
|
||||
refetchOrgs,
|
||||
removeSessionIdFromQuery,
|
||||
onOrganizationChange,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="flex gap-2">
|
||||
<DialogTitle>
|
||||
Move the current project to a different organization.
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{showContent ? (
|
||||
<>
|
||||
<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>
|
||||
<TransferProjectForm
|
||||
onCreateNewOrg={onCreateNewOrg}
|
||||
selectedOrganizationId={selectedOrganizationId}
|
||||
onCancel={onCancel}
|
||||
onOrganizationChange={onOrganizationChange}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<FinishOrgCreation
|
||||
onCompleted={handleOnCompleted}
|
||||
onError={onFinishOrgError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransferProjectDialogContent;
|
||||
@@ -0,0 +1,186 @@
|
||||
import { Badge } from '@/components/ui/v3/badge';
|
||||
import { ButtonWithLoading as Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/v3/select';
|
||||
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, 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 } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CREATE_NEW_ORG = 'createNewOrg';
|
||||
|
||||
const transferProjectFormSchema = z.object({
|
||||
organization: z.string(),
|
||||
});
|
||||
|
||||
export interface TransferProjectFormProps {
|
||||
onCreateNewOrg: () => void;
|
||||
onCancel: () => void;
|
||||
selectedOrganizationId?: string;
|
||||
onOrganizationChange(value: string): void;
|
||||
}
|
||||
|
||||
function TransferProjectForm({
|
||||
onCreateNewOrg,
|
||||
selectedOrganizationId,
|
||||
onCancel,
|
||||
onOrganizationChange,
|
||||
}: TransferProjectFormProps) {
|
||||
const { push } = useRouter();
|
||||
const { orgs, currentOrg } = useOrgs();
|
||||
const { project } = useProject();
|
||||
const currentUserId = useUserId();
|
||||
const [transferProject] = useBillingTransferAppMutation();
|
||||
|
||||
const form = useForm<z.infer<typeof transferProjectFormSchema>>({
|
||||
resolver: zodResolver(transferProjectFormSchema),
|
||||
defaultValues: {
|
||||
organization: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isNotEmptyValue(selectedOrganizationId)) {
|
||||
form.setValue('organization', selectedOrganizationId, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}, [selectedOrganizationId, form]);
|
||||
|
||||
const isUserAdminOfOrg = (org: Org, userId: string) =>
|
||||
org.members.some(
|
||||
(member) =>
|
||||
member.role === Organization_Members_Role_Enum.Admin &&
|
||||
member.user.id === userId,
|
||||
);
|
||||
|
||||
const createNewFormSelected = form.watch('organization') === CREATE_NEW_ORG;
|
||||
const submitButtonText = createNewFormSelected ? 'Continue' : 'Transfer';
|
||||
|
||||
const onSubmit = async (
|
||||
values: z.infer<typeof transferProjectFormSchema>,
|
||||
) => {
|
||||
const { organization } = values;
|
||||
|
||||
if (organization === CREATE_NEW_ORG) {
|
||||
onCreateNewOrg();
|
||||
} 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.',
|
||||
},
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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={onOrganizationChange} 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}
|
||||
onClick={onCancel}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={form.formState.isSubmitting || !form.formState.isDirty}
|
||||
loading={form.formState.isSubmitting}
|
||||
>
|
||||
{submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export default TransferProjectForm;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
import { type FinishOrgCreationOnCompletedCb } from '@/features/orgs/hooks/useFinishOrgCreation/useFinishOrgCreation';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useBillingTransferAppMutation } from '@/utils/__generated__/graphql';
|
||||
import { useRouter } from 'next/router';
|
||||
import { memo } from 'react';
|
||||
import FinishOrgCreation from './FinishOrgCreation';
|
||||
|
||||
interface Props {
|
||||
onCreateOrgError: () => void;
|
||||
onCancel: () => void;
|
||||
onCreateNewOrg: () => void;
|
||||
}
|
||||
|
||||
function UpgradeProjectDialogContent({
|
||||
onCreateNewOrg,
|
||||
onCancel,
|
||||
onCreateOrgError,
|
||||
}: Props) {
|
||||
const [transferProjectMutation] = useBillingTransferAppMutation();
|
||||
const { project } = useProject();
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const { push, query } = useRouter();
|
||||
const { session_id } = query;
|
||||
|
||||
const showContent = isEmptyValue(session_id);
|
||||
async function transferProject(newOrgSlug: string) {
|
||||
const { data } = await refetchOrgs();
|
||||
const newOrg = data.organizations.find((org) => org.slug === newOrgSlug);
|
||||
await execPromiseWithErrorToast(
|
||||
async () => {
|
||||
await transferProjectMutation({
|
||||
variables: {
|
||||
appID: project?.id,
|
||||
organizationID: newOrg?.id,
|
||||
},
|
||||
});
|
||||
await push(`/orgs/${newOrg?.slug}/projects`);
|
||||
},
|
||||
{
|
||||
loadingMessage: 'Upgrading project...',
|
||||
successMessage: 'Project has been upgraded successfully!',
|
||||
errorMessage: 'Error upgrading project. Please try again.',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const handleOnCompleted: FinishOrgCreationOnCompletedCb = async (data) => {
|
||||
await transferProject(data.Slug);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader className="flex gap-2">
|
||||
<DialogTitle>Upgrade project</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{showContent ? (
|
||||
<>
|
||||
<DialogDescription className="text-base">
|
||||
<span className="mb-4 block">
|
||||
To access premium features from a paid plan, a project must belong
|
||||
to an organization on that plan.
|
||||
</span>
|
||||
<span className="mb-4 block">
|
||||
Continue to create a new organization with a subscription plan.
|
||||
Your project will be automatically transferred to the new
|
||||
organization, unlocking all paid features.
|
||||
</span>
|
||||
<span className="block">
|
||||
Alternatively, you can transfer your project to an existing paid
|
||||
organization in your project's settings.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" type="button" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" onClick={onCreateNewOrg}>
|
||||
Continue
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<FinishOrgCreation
|
||||
onCompleted={handleOnCompleted}
|
||||
onError={onCreateOrgError}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(UpgradeProjectDialogContent);
|
||||
@@ -0,0 +1 @@
|
||||
export { default as TransferOrUpgradeProjectDialog } from './TransferOrUpgradeProjectDialog';
|
||||
@@ -1,306 +0,0 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/v3/dialog';
|
||||
|
||||
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,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
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, 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;
|
||||
}
|
||||
|
||||
const transferProjectFormSchema = z.object({
|
||||
organization: z.string(),
|
||||
});
|
||||
|
||||
export default function TransferProjectDialog({
|
||||
open,
|
||||
setOpen,
|
||||
}: TransferProjectDialogProps) {
|
||||
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,
|
||||
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),
|
||||
defaultValues: {
|
||||
organization: '',
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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 isUserAdminOfOrg = (org: Org, userId: string) =>
|
||||
org.members.some(
|
||||
(member) =>
|
||||
member.role === Organization_Members_Role_Enum.Admin &&
|
||||
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={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>
|
||||
|
||||
{!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"
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { default as TransferProjectDialog } from './TransferProjectDialog';
|
||||
@@ -130,6 +130,7 @@ export default function DeleteOrg() {
|
||||
e.preventDefault();
|
||||
await handleDeleteOrg();
|
||||
}}
|
||||
data-testid="deleteOrgButton"
|
||||
className={buttonVariants({ variant: 'destructive' })}
|
||||
disabled={
|
||||
deleting ||
|
||||
|
||||
@@ -13,13 +13,13 @@ Object.defineProperty(window, 'matchMedia', {
|
||||
value: vi.fn().mockImplementation(mockMatchMediaValue),
|
||||
});
|
||||
|
||||
export const getUseRouterObject = (session_id?: string) => ({
|
||||
export const getUseRouterObject = (session_id?: string, isReady = true) => ({
|
||||
basePath: '',
|
||||
pathname: '/orgs/xyz/projects/test-project',
|
||||
route: '/orgs/[orgSlug]/projects/[appSubdomain]',
|
||||
asPath: '/orgs/xyz/projects/test-project',
|
||||
isLocaleDomain: false,
|
||||
isReady: true,
|
||||
isReady,
|
||||
isPreview: false,
|
||||
query: {
|
||||
orgSlug: 'xyz',
|
||||
@@ -113,16 +113,18 @@ const fetchOrganizationNewRequestsResponseMock = async () => ({
|
||||
|
||||
const fetchPostOrganizationResponseMock = vi.fn();
|
||||
|
||||
test('if there is NO session_id in the url the billingPostOrganizationRequest is fetched from the server', async () => {
|
||||
test('if there is NO session_id in the url and the router is ready the billingPostOrganizationRequest is fetched from the server', async () => {
|
||||
server.use(getOrganizations);
|
||||
mocks.useOrganizationMemberInvitesLazyQuery.mockImplementation(
|
||||
fetchOrganizationMemberInvitesMock,
|
||||
);
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject());
|
||||
|
||||
mocks.useRouter.mockImplementation(() => getUseRouterObject(undefined, true));
|
||||
mocks.userData.mockImplementation(() => mockSession.user);
|
||||
mocks.useOrganizationNewRequestsLazyQuery.mockImplementation(() => [
|
||||
fetchOrganizationNewRequestsResponseMock,
|
||||
]);
|
||||
|
||||
mocks.usePostOrganizationRequestMutation.mockImplementation(() => [
|
||||
fetchPostOrganizationResponseMock.mockImplementation(() => ({
|
||||
data: {
|
||||
@@ -143,6 +145,39 @@ test('if there is NO session_id in the url the billingPostOrganizationRequest is
|
||||
expect(fetchPostOrganizationResponseMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('if the router is not ready the billingPostOrganizationRequest is not fetched from the server', async () => {
|
||||
server.use(getOrganizations);
|
||||
mocks.useOrganizationMemberInvitesLazyQuery.mockImplementation(
|
||||
fetchOrganizationMemberInvitesMock,
|
||||
);
|
||||
mocks.useRouter.mockImplementation(() =>
|
||||
getUseRouterObject(undefined, false),
|
||||
);
|
||||
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).not.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(
|
||||
|
||||
@@ -39,7 +39,7 @@ type Invite = OrganizationMemberInvitesQuery['organizationMemberInvites'][0];
|
||||
|
||||
export default function NotificationsTray() {
|
||||
const userData = useUserData();
|
||||
const { asPath, route, push, query } = useRouter();
|
||||
const { asPath, route, push, query, isReady: isRouterReady } = useRouter();
|
||||
const { session_id } = query;
|
||||
const { refetch: refetchOrgs } = useOrgs();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -113,6 +113,7 @@ export default function NotificationsTray() {
|
||||
if (
|
||||
userData &&
|
||||
!['/', '/orgs/verify'].includes(route) &&
|
||||
isRouterReady &&
|
||||
isEmptyValue(session_id)
|
||||
) {
|
||||
checkForPendingOrgRequests();
|
||||
@@ -120,6 +121,7 @@ export default function NotificationsTray() {
|
||||
}, [
|
||||
route,
|
||||
userData,
|
||||
isRouterReady,
|
||||
getOrganizationNewRequests,
|
||||
postOrganizationRequest,
|
||||
session_id,
|
||||
|
||||
@@ -32,9 +32,8 @@ function useFinishOrgCreation({
|
||||
|
||||
useEffect(() => {
|
||||
async function finishOrgCreation() {
|
||||
if (session_id && isAuthenticated) {
|
||||
if (router.isReady && session_id && isAuthenticated) {
|
||||
setLoading(true);
|
||||
|
||||
execPromiseWithErrorToast(
|
||||
async () => {
|
||||
const {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Container } from '@/components/layout/Container';
|
||||
import { Modal } from '@/components/ui/v1/Modal';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { TransferOrUpgradeProjectDialog } from '@/features/orgs/components/common/TransferOrUpgradeProjectDialog';
|
||||
import { ApplicationInfo } from '@/features/orgs/projects/common/components/ApplicationInfo';
|
||||
import { ApplicationPausedBanner } from '@/features/orgs/projects/common/components/ApplicationPausedBanner';
|
||||
import { RemoveApplicationModal } from '@/features/orgs/projects/common/components/RemoveApplicationModal';
|
||||
@@ -51,7 +51,7 @@ export default function ApplicationPaused() {
|
||||
Transfer
|
||||
</Button>
|
||||
|
||||
<TransferProjectDialog
|
||||
<TransferOrUpgradeProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Alert } from '@/components/ui/v2/Alert';
|
||||
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 { TransferOrUpgradeProjectDialog } from '@/features/orgs/components/common/TransferOrUpgradeProjectDialog';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
@@ -54,7 +54,7 @@ function UpgradeNotification({ description }: Props) {
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
<OpenTransferDialogButton onClick={handleTransferDialogOpen} />
|
||||
<TransferProjectDialog
|
||||
<TransferOrUpgradeProjectDialog
|
||||
open={transferProjectDialogOpen}
|
||||
setOpen={setTransferProjectDialogOpen}
|
||||
/>
|
||||
|
||||
@@ -5,10 +5,10 @@ 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';
|
||||
import UpgradeProjectDialog from './UpgradeProjectDialog';
|
||||
|
||||
export default function OverviewTopBar() {
|
||||
const isPlatform = useIsPlatform();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { OpenTransferDialogButton } from '@/components/common/OpenTransferDialogButton';
|
||||
import { TransferProjectDialog } from '@/features/orgs/components/common/TransferProjectDialog';
|
||||
import { TransferOrUpgradeProjectDialog } from '@/features/orgs/components/common/TransferOrUpgradeProjectDialog';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
function UpgradeProjectDialog() {
|
||||
@@ -12,7 +12,7 @@ function UpgradeProjectDialog() {
|
||||
buttonText="Upgrade project"
|
||||
onClick={handleDialogOpen}
|
||||
/>
|
||||
<TransferProjectDialog open={open} setOpen={setOpen} />
|
||||
<TransferOrUpgradeProjectDialog open={open} setOpen={setOpen} isUpgrade />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
import { BaseLayout } from '@/components/layout/BaseLayout';
|
||||
import { Header } from '@/components/layout/Header';
|
||||
import { FinishOrgCreationProcess } from '@/features/orgs/components/common/FinishOrgCreationProcess';
|
||||
import { useFinishOrgCreation } from '@/features/orgs/hooks/useFinishOrgCreation';
|
||||
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
|
||||
import type { PostOrganizationRequestMutation } from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
@@ -33,15 +32,12 @@ export default function PostCheckout() {
|
||||
[router],
|
||||
);
|
||||
|
||||
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">
|
||||
<FinishOrgCreationProcess
|
||||
loading={loading}
|
||||
status={status}
|
||||
onCompleted={onCompleted}
|
||||
loadingMessage="Processing new organization request"
|
||||
successMessage="Organization created successfully. Redirecting..."
|
||||
pendingMessage="Organization creation is pending..."
|
||||
|
||||
Reference in New Issue
Block a user