Compare commits
24 Commits
@nhost/rea
...
@nhost/das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdff345ac | ||
|
|
97db63791b | ||
|
|
a0931e282f | ||
|
|
e87505c564 | ||
|
|
c0635ae1c7 | ||
|
|
d2a9a9ae1d | ||
|
|
c97b43f149 | ||
|
|
2026bb7a9c | ||
|
|
1bc1e30f5e | ||
|
|
85526782f2 | ||
|
|
fad7f640de | ||
|
|
5ff4dd6e40 | ||
|
|
0bf28085b7 | ||
|
|
b302dbd27d | ||
|
|
72a365c5fc | ||
|
|
d11363a74c | ||
|
|
1bc2fabe59 | ||
|
|
f8243f9434 | ||
|
|
d9eb90604d | ||
|
|
cef647194d | ||
|
|
efd68c3f92 | ||
|
|
233232b06f | ||
|
|
5e962300f6 | ||
|
|
048b3389e6 |
19
.github/labeler.yml
vendored
19
.github/labeler.yml
vendored
@@ -1,24 +1,25 @@
|
||||
dashboard:
|
||||
- dashboard/**/*
|
||||
- any:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: ['dashboard/**/*']
|
||||
|
||||
documentation:
|
||||
- any:
|
||||
- docs/**/*
|
||||
- any: ['docs/**/*']
|
||||
|
||||
examples:
|
||||
- examples/**/*
|
||||
- any: ['examples/**/*']
|
||||
|
||||
sdk:
|
||||
- packages/**/*
|
||||
- any: ['packages/**/*']
|
||||
|
||||
integrations:
|
||||
- integrations/**/*
|
||||
- any: ['integrations/**/*']
|
||||
|
||||
react:
|
||||
- '{packages,examples,integrations}/*react*/**/*'
|
||||
- any: ['{packages,examples,integrations}/*react*/**/*']
|
||||
|
||||
nextjs:
|
||||
- '{packages,examples}/*next*/**/*'
|
||||
- any: ['{packages,examples}/*next*/**/*']
|
||||
|
||||
vue:
|
||||
- '{packages,examples,integrations}/*vue*/**/*'
|
||||
- any: ['{packages,examples,integrations}/*vue*/**/*']
|
||||
|
||||
16
.github/workflows/ci.yaml
vendored
16
.github/workflows/ci.yaml
vendored
@@ -7,12 +7,14 @@ on:
|
||||
- 'assets/**'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'docs/**'
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
paths-ignore:
|
||||
- 'assets/**'
|
||||
- '**.md'
|
||||
- 'LICENSE'
|
||||
- 'docs/**'
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: nhost
|
||||
@@ -27,7 +29,8 @@ env:
|
||||
NHOST_PRO_TEST_PROJECT_NAME: ${{ vars.NHOST_PRO_TEST_PROJECT_NAME }}
|
||||
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_PROJECT_ADMIN_SECRET: '${{ secrets.NHOST_TEST_PROJECT_ADMIN_SECRET }}'
|
||||
NHOST_TEST_FREE_USER_EMAILS: ${{ secrets.NHOST_TEST_FREE_USER_EMAILS }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -169,6 +172,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,13 +184,16 @@ 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'
|
||||
working-directory: ./nhost-test-project
|
||||
run: nhost down
|
||||
- name: Stop Nhost CLI for packages
|
||||
if: always() && (matrix.package.path == 'packages/hasura-auth-js' || matrix.package.path == 'packages/hasura-storage-js')
|
||||
working-directory: ./${{ matrix.package.path }}
|
||||
run: nhost down
|
||||
- id: file-name
|
||||
if: ${{ failure() }}
|
||||
name: Transform package name into a valid file name
|
||||
|
||||
28
.github/workflows/deploy-dashboard.yaml
vendored
28
.github/workflows/deploy-dashboard.yaml
vendored
@@ -56,3 +56,31 @@ jobs:
|
||||
vercel pull --environment=production --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel build --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
vercel deploy --prebuilt --prod --token=${{ secrets.DASHBOARD_VERCEL_DEPLOY_TOKEN }}
|
||||
|
||||
- name: Send Discord notification (success)
|
||||
if: success()
|
||||
uses: tsickert/discord-webhook@v7.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_PRODUCTION }}
|
||||
embed-title: "Dashboard Deployment"
|
||||
embed-description: |
|
||||
**Status**: success
|
||||
**Triggered by**: ${{ github.actor }}
|
||||
|
||||
**Inputs**:
|
||||
- Git Ref: ${{ inputs.git_ref }}
|
||||
embed-color: '5763719'
|
||||
|
||||
- name: Send Discord notification (failure)
|
||||
if: failure()
|
||||
uses: tsickert/discord-webhook@v7.0.0
|
||||
with:
|
||||
webhook-url: ${{ secrets.DISCORD_WEBHOOK_PRODUCTION }}
|
||||
embed-title: "Dashboard Deployment"
|
||||
embed-description: |
|
||||
**Status**: failure
|
||||
**Triggered by**: ${{ github.actor }}
|
||||
|
||||
**Inputs**:
|
||||
- Git Ref: ${{ inputs.git_ref }}
|
||||
embed-color: '15548997'
|
||||
|
||||
7
.github/workflows/labeler.yaml
vendored
7
.github/workflows/labeler.yaml
vendored
@@ -3,13 +3,12 @@ on:
|
||||
- pull_request_target
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
labeler:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v4
|
||||
- uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: '${{ secrets.GH_PAT }}'
|
||||
sync-labels: ''
|
||||
repo-token: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# @nhost/dashboard
|
||||
|
||||
## 2.29.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
- a0931e2: fix: improve logs time range and filter selection
|
||||
- c0635ae: feat (dashboard): Add information about that free organization cannot be upgraded.
|
||||
- e87505c: fix: can downsize postgres storage capacity using local dashboard
|
||||
|
||||
## 2.28.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- 8552678: feat: dashboard: add additional events to segment
|
||||
- 0bf2808: chore: refresh metadata before end-to-end tests
|
||||
- 72a365c: fix: correct graphql page roles dropdown's source
|
||||
- cef6471: fix: dashboard: add anonid to user's metadata
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- 233232b: feat (dashboard): improve Upgrade project dialog
|
||||
- Updated dependencies [d9eb906]
|
||||
- @nhost/nextjs@2.2.7
|
||||
- @nhost/react-apollo@17.0.4
|
||||
|
||||
## 2.27.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -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);
|
||||
|
||||
49
dashboard/e2e/setup/refresh-metadata.setup.ts
Normal file
49
dashboard/e2e/setup/refresh-metadata.setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/* eslint-disable no-console */
|
||||
import { TEST_PROJECT_ADMIN_SECRET, TEST_PROJECT_SUBDOMAIN } from '@/e2e/env';
|
||||
import { test as setup } from '@playwright/test';
|
||||
|
||||
setup('refresh metadata', async () => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://${TEST_PROJECT_SUBDOMAIN}.hasura.eu-central-1.staging.nhost.run/v1/metadata`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-hasura-admin-secret': TEST_PROJECT_ADMIN_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
args: [
|
||||
{
|
||||
type: 'reload_metadata',
|
||||
args: {
|
||||
reload_remote_schemas: [],
|
||||
reload_sources: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
args: {},
|
||||
type: 'get_inconsistent_metadata',
|
||||
},
|
||||
],
|
||||
source: 'default',
|
||||
type: 'bulk',
|
||||
}),
|
||||
},
|
||||
);
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = `[${body.code}]:${body.error}`;
|
||||
console.log(message);
|
||||
throw new Error(message);
|
||||
} else {
|
||||
console.log('Metadata is consistent.');
|
||||
}
|
||||
} catch (error) {
|
||||
// Log safe error information
|
||||
console.error(
|
||||
'Failed to refresh metadata:',
|
||||
error instanceof Error ? error.message : 'Unknown error',
|
||||
);
|
||||
throw new Error('Failed to refresh metadata');
|
||||
}
|
||||
});
|
||||
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()));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost/dashboard",
|
||||
"version": "2.27.0",
|
||||
"version": "2.29.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
@@ -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",
|
||||
@@ -87,7 +89,7 @@
|
||||
"just-kebab-case": "^4.2.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lucide-react": "^0.416.0",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.26",
|
||||
"next-nprogress-bar": "^2.3.13",
|
||||
"next-seo": "^6.5.0",
|
||||
"next-themes": "^0.3.0",
|
||||
@@ -194,7 +196,7 @@
|
||||
"tailwindcss": "^3.4.12",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths-webpack-plugin": "^4.1.0",
|
||||
"vite": "^5.4.17",
|
||||
"vite": "^5.4.19",
|
||||
"vite-tsconfig-paths": "^4.3.2",
|
||||
"vitest": "^0.32.4"
|
||||
},
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -26,7 +26,7 @@ function InfoAlert({
|
||||
<Alert className={alertClassNames}>
|
||||
{icon && <div>{icon}</div>}
|
||||
<div>
|
||||
{title && <AlertTitle>{title}</AlertTitle>}
|
||||
{title && <AlertTitle className="font-semibold">{title}</AlertTitle>}
|
||||
{children && (
|
||||
<AlertDescription className={descClassNames}>
|
||||
{children}
|
||||
|
||||
@@ -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,7 +1,6 @@
|
||||
import { useUI } from '@/components/common/UIProvider';
|
||||
import { ActivityIndicator } from '@/components/ui/v2/ActivityIndicator';
|
||||
import { ArrowSquareOutIcon } from '@/components/ui/v2/icons/ArrowSquareOutIcon';
|
||||
import { Link } from '@/components/ui/v2/Link';
|
||||
import { Button } from '@/components/ui/v3/button';
|
||||
import {
|
||||
Dialog,
|
||||
@@ -21,6 +20,8 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/v3/form';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/v3/radio-group';
|
||||
import { InfoAlert } from '@/features/orgs/components/InfoAlert';
|
||||
import TextLink from '@/features/orgs/projects/common/components/TextLink/TextLink';
|
||||
import { planDescriptions } from '@/features/orgs/projects/common/utils/planDescriptions';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
@@ -35,6 +36,14 @@ import { useEffect, useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
function NewOrgButton() {
|
||||
return (
|
||||
<strong className="inline-flex items-center justify-center gap-2 px-1">
|
||||
<span>+ New Organization</span>
|
||||
</strong>
|
||||
);
|
||||
}
|
||||
|
||||
const changeOrgPlanForm = z.object({
|
||||
plan: z.string(),
|
||||
});
|
||||
@@ -48,6 +57,8 @@ export default function SubscriptionPlan() {
|
||||
const [fetchOrganizationCustomePortalLink, { loading }] =
|
||||
useBillingOrganizationCustomePortalLazyQuery();
|
||||
|
||||
const isFreeOrg = org?.plan.isFree;
|
||||
|
||||
const form = useForm<z.infer<typeof changeOrgPlanForm>>({
|
||||
resolver: zodResolver(changeOrgPlanForm),
|
||||
defaultValues: {
|
||||
@@ -125,7 +136,7 @@ export default function SubscriptionPlan() {
|
||||
<div className="flex w-full flex-col gap-1 border-b p-4">
|
||||
<h4 className="font-medium">Subscription plan</h4>
|
||||
</div>
|
||||
<div className="flex w-full flex-col justify-between gap-8 border-b p-4 md:flex-row">
|
||||
<div className="flex w-full flex-col justify-between gap-8 p-4 md:flex-row">
|
||||
<div className="flex basis-1/2 flex-col gap-4">
|
||||
<span className="font-medium">Organization name</span>
|
||||
<span className="font-medium">{org?.name}</span>
|
||||
@@ -152,31 +163,26 @@ export default function SubscriptionPlan() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col-reverse items-end justify-between gap-2 p-4 md:flex-row md:items-center md:gap-0">
|
||||
{isFreeOrg && (
|
||||
<div className="flex w-full flex-col justify-between gap-8 p-4 md:flex-row">
|
||||
<InfoAlert title="Personal Organizations can not be upgraded.">
|
||||
You may create a new organization with premium features by
|
||||
clicking on the <NewOrgButton /> button in the left sidebar.
|
||||
</InfoAlert>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col-reverse items-end justify-between gap-2 border-t p-4 md:flex-row md:items-center md:gap-0">
|
||||
<div>
|
||||
<span>For a complete list of features, visit our </span>
|
||||
<Link
|
||||
href="https://nhost.io/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<TextLink href="https://nhost.io/pricing">
|
||||
pricing
|
||||
<ArrowSquareOutIcon className="mb-[2px] ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</TextLink>
|
||||
<span> You can also visit our </span>
|
||||
<Link
|
||||
href="https://docs.nhost.io/platform/cloud/billing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<TextLink href="https://docs.nhost.io/platform/cloud/billing">
|
||||
documentation
|
||||
<ArrowSquareOutIcon className="mb-[2px] ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</TextLink>
|
||||
<span> for billing information</span>
|
||||
</div>
|
||||
<div className="flex w-full flex-row items-center justify-end gap-2">
|
||||
@@ -245,7 +251,7 @@ export default function SubscriptionPlan() {
|
||||
</div>
|
||||
|
||||
<div className="mt-0 flex h-full items-center text-xl font-semibold">
|
||||
{plan.isFree ? 'Free' : `${plan.price}/mo`}
|
||||
{isFreeOrg ? 'Free' : `${plan.price}/mo`}
|
||||
</div>
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
@@ -264,16 +270,10 @@ export default function SubscriptionPlan() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="mailto:hello@nhost.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="hover"
|
||||
className="font-medium"
|
||||
>
|
||||
<TextLink href="mailto:hello@nhost.io">
|
||||
Contact us
|
||||
<ArrowSquareOutIcon className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
@@ -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,116 @@
|
||||
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 { analytics } from '@/lib/segment';
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
analytics.track('Project Upgraded', {
|
||||
projectId: project?.id,
|
||||
projectName: project?.name,
|
||||
projectSubdomain: project?.subdomain,
|
||||
newOrganizationId: newOrg?.id,
|
||||
newOrganizationName: newOrg?.name,
|
||||
newOrganizationSlug: newOrg?.slug,
|
||||
newOrganizationPlan: newOrg?.plan?.name,
|
||||
newOrganizationPlanId: newOrg?.plan?.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,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { OrgInvite } from '@/features/orgs/components/members/components/OrgInvi
|
||||
import { useIsOrgAdmin } from '@/features/orgs/hooks/useIsOrgAdmin';
|
||||
import { useCurrentOrg } from '@/features/orgs/projects/hooks/useCurrentOrg';
|
||||
import execPromiseWithErrorToast from '@/features/orgs/utils/execPromiseWithErrorToast/execPromiseWithErrorToast';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import {
|
||||
Organization_Members_Role_Enum,
|
||||
useGetOrganizationInvitesQuery,
|
||||
@@ -91,6 +92,16 @@ export default function PendingInvites() {
|
||||
},
|
||||
});
|
||||
|
||||
analytics.track('Organization Invite Sent', {
|
||||
organizationId: org?.id,
|
||||
organizationName: org?.name,
|
||||
organizationSlug: org?.slug,
|
||||
organizationPlan: org?.plan?.name,
|
||||
organizationPlanId: org?.plan?.id,
|
||||
inviteeEmail: email,
|
||||
inviteeRole: role,
|
||||
});
|
||||
|
||||
setInviteDialogOpen(false);
|
||||
setOrgInviteError(null);
|
||||
form.reset();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SquareArrowUpRightIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
|
||||
@@ -5,7 +6,8 @@ function TextLink({
|
||||
href,
|
||||
children,
|
||||
target = '_blank',
|
||||
}: PropsWithChildren<{ href: string; target?: string }>) {
|
||||
withIcon = false,
|
||||
}: PropsWithChildren<{ href: string; target?: string; withIcon?: boolean }>) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
@@ -14,6 +16,7 @@ function TextLink({
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
{withIcon && <SquareArrowUpRightIcon className="h-4 w-4" />}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function useGetPostgresVersion() {
|
||||
});
|
||||
|
||||
const { version } = postgresSettingsData?.config?.postgres || {};
|
||||
const { major, minor } = splitPostgresMajorMinorVersions(version);
|
||||
const { major, minor } = splitPostgresMajorMinorVersions(version || '');
|
||||
|
||||
return {
|
||||
version,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useGetPostgresSettingsQuery,
|
||||
useUpdateConfigMutation,
|
||||
} from '@/generated/graphql';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { ApplicationStatus } from '@/types/application';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
@@ -46,7 +47,9 @@ export default function DatabaseStorageCapacity() {
|
||||
const localMimirClient = useLocalMimirClient();
|
||||
const { project } = useProject();
|
||||
|
||||
const isFreeProject = !!org?.plan?.isFree;
|
||||
const isFreeProject = isEmptyValue(org) ? false : org.plan.isFree;
|
||||
|
||||
const shouldShowUpdateCapacityWarning = !isFreeProject && isPlatform;
|
||||
|
||||
const {
|
||||
data,
|
||||
@@ -98,6 +101,10 @@ export default function DatabaseStorageCapacity() {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!isPlatform) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (maintenanceActive) {
|
||||
return true;
|
||||
}
|
||||
@@ -107,7 +114,13 @@ export default function DatabaseStorageCapacity() {
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [isDirty, maintenanceActive, decreasingSize, applicationPause]);
|
||||
}, [
|
||||
isDirty,
|
||||
maintenanceActive,
|
||||
decreasingSize,
|
||||
applicationPause,
|
||||
isPlatform,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data && !loading) {
|
||||
@@ -195,7 +208,7 @@ export default function DatabaseStorageCapacity() {
|
||||
helperText={formState.errors.capacity?.message}
|
||||
/>
|
||||
</Box>
|
||||
{!isFreeProject && (
|
||||
{shouldShowUpdateCapacityWarning && (
|
||||
<DatabaseStorageCapacityWarning
|
||||
state={state}
|
||||
decreasingSize={decreasingSize}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { EditRepositoryAndBranchSettings } from '@/features/orgs/projects/git/co
|
||||
import type { EditRepositorySettingsFormData } from '@/features/orgs/projects/git/common/components/EditRepositorySettings';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { useUpdateApplicationMutation } from '@/generated/graphql';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import { discordAnnounce } from '@/utils/discordAnnounce';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
@@ -49,6 +50,17 @@ export default function EditRepositorySettingsModal({
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (selectedRepoId) {
|
||||
analytics.track('Project Connected to GitHub', {
|
||||
projectId: project.id,
|
||||
projectName: project.name,
|
||||
projectSubdomain: project.subdomain,
|
||||
repositoryId: selectedRepoId,
|
||||
productionBranch: data.productionBranch,
|
||||
baseFolder: data.repoBaseFolder,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await updateApp({
|
||||
variables: {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { UserSelect } from '@/features/orgs/projects/graphql/common/components/UserSelect';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Component that combines user selection and role selection functionality
|
||||
*/
|
||||
interface UserAndRoleSelectProps {
|
||||
/**
|
||||
* Function to be called when the user changes.
|
||||
*/
|
||||
onUserChange: (userId: string) => void;
|
||||
/**
|
||||
* Function to be called when the role changes.
|
||||
*/
|
||||
onRoleChange: (role: string) => void;
|
||||
}
|
||||
|
||||
export default function UserAndRoleSelect({
|
||||
onUserChange,
|
||||
onRoleChange,
|
||||
}: UserAndRoleSelectProps) {
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>([]);
|
||||
const [role, setRole] = useState<string>(() => availableRoles[0]);
|
||||
|
||||
const handleUserChange = (userId: string, availableUserRoles: string[]) => {
|
||||
onUserChange(userId);
|
||||
setAvailableRoles(availableUserRoles);
|
||||
|
||||
const newRole = availableUserRoles[0];
|
||||
|
||||
if (newRole) {
|
||||
setRole(newRole);
|
||||
onRoleChange(newRole);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-flow-col md:grid-cols-[initial]">
|
||||
<UserSelect
|
||||
className="col-span-1 md:col-auto md:w-52"
|
||||
onUserChange={handleUserChange}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="role-select"
|
||||
label="Role"
|
||||
value={role}
|
||||
onChange={(_event, value) => {
|
||||
if (typeof value === 'string') {
|
||||
setRole(value);
|
||||
onRoleChange(value);
|
||||
}
|
||||
}}
|
||||
hideEmptyHelperText
|
||||
className="col-span-1 md:col-auto md:w-52"
|
||||
>
|
||||
{availableRoles.map((availableRole) => (
|
||||
<Option value={availableRole} key={availableRole}>
|
||||
{availableRole}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as UserAndRoleSelect } from './UserAndRoleSelect';
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Autocomplete } from '@/components/ui/v2/Autocomplete';
|
||||
import { useRemoteApplicationGQLClient } from '@/features/orgs/hooks/useRemoteApplicationGQLClient';
|
||||
import { DEFAULT_ROLES } from '@/features/orgs/projects/graphql/common/utils/constants';
|
||||
import { getAdminRoles } from '@/features/orgs/projects/roles/settings/utils/getAdminRoles';
|
||||
import { isNotEmptyValue } from '@/lib/utils';
|
||||
import {
|
||||
useRemoteAppGetUsersCustomLazyQuery,
|
||||
type RemoteAppGetUsersCustomQuery,
|
||||
useRemoteAppGetUsersAndAuthRolesLazyQuery,
|
||||
type RemoteAppGetUsersAndAuthRolesQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { debounce } from '@mui/material/utils';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
@@ -26,22 +27,26 @@ export default function UserSelect({
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
const [active, setActive] = useState(true);
|
||||
const [adminAuthRoles, setAdminAuthRoles] = useState<string[]>(() =>
|
||||
getAdminRoles(),
|
||||
); // Roles from the auth.roles table
|
||||
|
||||
const userApplicationClient = useRemoteApplicationGQLClient();
|
||||
|
||||
const [fetchAppUsers, { loading }] = useRemoteAppGetUsersCustomLazyQuery({
|
||||
client: userApplicationClient,
|
||||
variables: {
|
||||
where: {},
|
||||
limit: 250,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
const [fetchAppUsers, { loading }] =
|
||||
useRemoteAppGetUsersAndAuthRolesLazyQuery({
|
||||
client: userApplicationClient,
|
||||
variables: {
|
||||
where: {},
|
||||
limit: 250,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const fetchUsers = useCallback(
|
||||
async (
|
||||
request: { input: string },
|
||||
callback: (results?: RemoteAppGetUsersCustomQuery['users']) => void,
|
||||
callback: (results?: RemoteAppGetUsersAndAuthRolesQuery) => void,
|
||||
) => {
|
||||
const ilike = `%${request.input === 'Admin' ? '' : request.input}%`;
|
||||
const { data } = await fetchAppUsers({
|
||||
@@ -55,7 +60,7 @@ export default function UserSelect({
|
||||
},
|
||||
});
|
||||
|
||||
callback(data?.users);
|
||||
callback(data);
|
||||
},
|
||||
[fetchAppUsers, userApplicationClient],
|
||||
);
|
||||
@@ -65,11 +70,28 @@ export default function UserSelect({
|
||||
useEffect(() => {
|
||||
fetchOptions({ input: inputValue }, (results) => {
|
||||
if (active || inputValue === '') {
|
||||
setUsers(results || []);
|
||||
if (
|
||||
isNotEmptyValue(results?.users) &&
|
||||
isNotEmptyValue(results?.authRoles)
|
||||
) {
|
||||
setUsers(results.users);
|
||||
const newAuthRoles = results.authRoles.map(
|
||||
(authRole) => authRole.role,
|
||||
);
|
||||
setAdminAuthRoles(newAuthRoles);
|
||||
} else {
|
||||
setUsers([]);
|
||||
setAdminAuthRoles(getAdminRoles());
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [inputValue, fetchOptions, active]);
|
||||
|
||||
useEffect(() => {
|
||||
onUserChange('admin', getAdminRoles(adminAuthRoles));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adminAuthRoles]);
|
||||
|
||||
const autocompleteOptions = [
|
||||
{
|
||||
value: 'admin',
|
||||
@@ -109,24 +131,19 @@ export default function UserSelect({
|
||||
}
|
||||
|
||||
if (userId === 'admin') {
|
||||
onUserChange('admin', DEFAULT_ROLES);
|
||||
|
||||
onUserChange('admin', getAdminRoles(adminAuthRoles));
|
||||
return;
|
||||
}
|
||||
|
||||
const user: RemoteAppGetUsersCustomQuery['users'][0] = users.find(
|
||||
const user: RemoteAppGetUsersAndAuthRolesQuery['users'][0] = users.find(
|
||||
({ id }) => id === userId,
|
||||
);
|
||||
|
||||
const roles = user?.roles?.map(({ role }) => role);
|
||||
if (isNotEmptyValue(user?.roles)) {
|
||||
const roles = user.roles.map(({ role }) => role);
|
||||
|
||||
onUserChange(userId, roles ?? DEFAULT_ROLES);
|
||||
|
||||
fetchUsers({ input: '' }, (results) => {
|
||||
if (results) {
|
||||
setUsers(results);
|
||||
}
|
||||
});
|
||||
onUserChange(userId, roles);
|
||||
}
|
||||
}}
|
||||
onInputChange={(event, newInputValue) => {
|
||||
setInputValue(newInputValue);
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
const DEFAULT_ROLES = ['admin', 'public', 'anonymous'];
|
||||
|
||||
export { DEFAULT_ROLES };
|
||||
@@ -1 +0,0 @@
|
||||
export * from './constants';
|
||||
@@ -6,11 +6,13 @@ import { Dropdown } from '@/components/ui/v2/Dropdown';
|
||||
import { CalendarIcon } from '@/components/ui/v2/icons/CalendarIcon';
|
||||
import { ChevronDownIcon } from '@/components/ui/v2/icons/ChevronDownIcon';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import type { LogsFilterFormValues } from '@/features/orgs/projects/logs/components/LogsHeader';
|
||||
import { LogsTimePicker } from '@/features/orgs/projects/logs/components/LogsTimePicker';
|
||||
import { DATEPICKER_DISPLAY_FORMAT } from '@/features/orgs/projects/logs/utils/constants/datePicker';
|
||||
import { usePreviousData } from '@/hooks/usePreviousData';
|
||||
import { format } from 'date-fns';
|
||||
import { useState } from 'react';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export interface LogsDatePickerProps extends DatePickerProps {
|
||||
@@ -36,6 +38,7 @@ function LogsDatePicker({
|
||||
value,
|
||||
}: LogsDatePickerProps) {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(value);
|
||||
const { setValue } = useFormContext<LogsFilterFormValues>();
|
||||
const { button: buttonSlotProps } = {
|
||||
button: componentsProps?.button || {},
|
||||
};
|
||||
@@ -45,6 +48,11 @@ function LogsDatePicker({
|
||||
// going to display the last state set.
|
||||
const previousDate = usePreviousData(selectedDate);
|
||||
|
||||
const handleDateChange = (newValue: Date) => {
|
||||
setSelectedDate(new Date(newValue));
|
||||
setValue('interval', null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown.Root>
|
||||
<div className="grid grid-flow-col gap-x-3">
|
||||
@@ -84,9 +92,7 @@ function LogsDatePicker({
|
||||
<Dropdown.Content>
|
||||
<DatePicker
|
||||
value={disabled ? previousDate : selectedDate}
|
||||
onChange={(newValue) => {
|
||||
setSelectedDate(new Date(newValue));
|
||||
}}
|
||||
onChange={handleDateChange}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
/>
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from '@/features/orgs/projects/logs/utils/constants/services';
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
import { useGetServiceLabelValuesQuery } from '@/utils/__generated__/graphql';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import { DEFAULT_LOG_INTERVAL } from '@/utils/constants/common';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
@@ -28,6 +28,7 @@ import LogsServiceFilter from './LogsServiceFilter';
|
||||
export const validationSchema = Yup.object({
|
||||
from: Yup.date(),
|
||||
to: Yup.date().nullable(),
|
||||
interval: Yup.number().nullable(), // in minutes
|
||||
service: Yup.string().oneOf(Object.values(AvailableLogsService)),
|
||||
regexFilter: Yup.string(),
|
||||
});
|
||||
@@ -44,11 +45,17 @@ interface LogsHeaderProps extends Omit<BoxProps, 'children'> {
|
||||
* Function to be called when the user submits the filters form
|
||||
*/
|
||||
onSubmitFilterValues: (value: LogsFilterFormValues) => void;
|
||||
/**
|
||||
*
|
||||
* Function to be called to force a refetch of the logs when the form is not dirty and the user submits the form
|
||||
*/
|
||||
onRefetch: () => void;
|
||||
}
|
||||
|
||||
export default function LogsHeader({
|
||||
loading,
|
||||
onSubmitFilterValues,
|
||||
onRefetch,
|
||||
...props
|
||||
}: LogsHeaderProps) {
|
||||
const { project } = useProject();
|
||||
@@ -83,16 +90,20 @@ export default function LogsHeader({
|
||||
|
||||
const form = useForm<LogsFilterFormValues>({
|
||||
defaultValues: {
|
||||
from: subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
from: subMinutes(new Date(), DEFAULT_LOG_INTERVAL),
|
||||
to: new Date(),
|
||||
regexFilter: '',
|
||||
service: AvailableLogsService.ALL,
|
||||
interval: DEFAULT_LOG_INTERVAL,
|
||||
},
|
||||
reValidateMode: 'onSubmit',
|
||||
resolver: yupResolver(validationSchema),
|
||||
reValidateMode: 'onSubmit',
|
||||
});
|
||||
const { formState } = form;
|
||||
|
||||
const { register, watch, getValues } = form;
|
||||
const isNotDirty = Object.keys(formState.dirtyFields).length === 0;
|
||||
|
||||
const { register, watch, getValues, setValue } = form;
|
||||
|
||||
const service = watch('service');
|
||||
|
||||
@@ -100,8 +111,33 @@ export default function LogsHeader({
|
||||
onSubmitFilterValues(getValues());
|
||||
}, [service, getValues, onSubmitFilterValues]);
|
||||
|
||||
const handleSubmit = (values: LogsFilterFormValues) =>
|
||||
const handleSubmit = (values: LogsFilterFormValues) => {
|
||||
// If there's an interval set, recalculate the dates
|
||||
if (values.interval) {
|
||||
const now = new Date();
|
||||
const newValues = {
|
||||
...values,
|
||||
from: subMinutes(now, values.interval),
|
||||
to: now,
|
||||
interval: values.interval,
|
||||
};
|
||||
|
||||
// Update form values before submitting, to ensure the dates have the current date if selected an interval
|
||||
setValue('from', newValues.from);
|
||||
setValue('to', newValues.to);
|
||||
setValue('interval', newValues.interval);
|
||||
|
||||
onSubmitFilterValues(newValues);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the form is not dirty, force a refetch of the logs
|
||||
if (isNotDirty) {
|
||||
onRefetch();
|
||||
}
|
||||
|
||||
onSubmitFilterValues(values);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -198,11 +234,13 @@ export default function LogsHeader({
|
||||
type="submit"
|
||||
className="h-10"
|
||||
startIcon={
|
||||
loading ? (
|
||||
<ActivityIndicator className="h-4 w-4" />
|
||||
) : (
|
||||
<SearchIcon />
|
||||
)
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
{loading ? (
|
||||
<ActivityIndicator className="h-5 w-5" />
|
||||
) : (
|
||||
<SearchIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
disabled={loading}
|
||||
>
|
||||
|
||||
@@ -27,11 +27,18 @@ function LogsToDatePickerLiveButton() {
|
||||
if (isLive) {
|
||||
setValue('from', subMinutes(new Date(), 20));
|
||||
setValue('to', new Date());
|
||||
setValue('interval', null);
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('to', null);
|
||||
setCurrentTime(new Date());
|
||||
setValue('interval', null);
|
||||
}
|
||||
|
||||
function handleChangeToDate(date: Date) {
|
||||
setValue('to', date);
|
||||
setValue('interval', null);
|
||||
}
|
||||
|
||||
useInterval(() => setCurrentTime(new Date()), isLive ? 1000 : 0);
|
||||
@@ -43,7 +50,7 @@ function LogsToDatePickerLiveButton() {
|
||||
label="To"
|
||||
value={!isLive ? to : currentTime}
|
||||
disabled={isLive}
|
||||
onChange={(date: Date) => setValue('to', date)}
|
||||
onChange={handleChangeToDate}
|
||||
minDate={from}
|
||||
maxDate={new Date()}
|
||||
componentsProps={{
|
||||
@@ -84,7 +91,7 @@ function LogsRangeSelectorIntervalPickers({
|
||||
const applicationCreationDate = new Date(project.createdAt);
|
||||
|
||||
const { setValue, getValues } = useFormContext<LogsFilterFormValues>();
|
||||
const { from } = useWatch<LogsFilterFormValues>();
|
||||
const { from, interval } = useWatch<LogsFilterFormValues>();
|
||||
|
||||
const { handleClose } = useDropdown();
|
||||
|
||||
@@ -101,6 +108,12 @@ function LogsRangeSelectorIntervalPickers({
|
||||
}: LogsCustomInterval) {
|
||||
setValue('from', subMinutes(new Date(), minutesToDecreaseFromCurrentDate));
|
||||
setValue('to', new Date());
|
||||
setValue('interval', minutesToDecreaseFromCurrentDate);
|
||||
}
|
||||
|
||||
function handleChangeFromDate(date: Date) {
|
||||
setValue('from', date);
|
||||
setValue('interval', null);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -109,7 +122,7 @@ function LogsRangeSelectorIntervalPickers({
|
||||
<LogsDatePicker
|
||||
label="From"
|
||||
value={from}
|
||||
onChange={(date) => setValue('from', date)}
|
||||
onChange={handleChangeFromDate}
|
||||
minDate={applicationCreationDate}
|
||||
maxDate={new Date()}
|
||||
/>
|
||||
@@ -122,7 +135,11 @@ function LogsRangeSelectorIntervalPickers({
|
||||
<Button
|
||||
key={logInterval.label}
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
color={
|
||||
interval === logInterval.minutesToDecreaseFromCurrentDate
|
||||
? 'primary'
|
||||
: 'secondary'
|
||||
}
|
||||
className="self-center"
|
||||
onClick={() => handleIntervalChange(logInterval)}
|
||||
>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { expect, test } from 'vitest';
|
||||
import getAdminRoles from './getAdminRoles';
|
||||
|
||||
test('should return an array with the default admin roles if no roles are passed', () => {
|
||||
expect(getAdminRoles()).toEqual(['admin', 'public', 'anonymous']);
|
||||
expect(getAdminRoles(null)).toEqual(['admin', 'public', 'anonymous']);
|
||||
expect(getAdminRoles(undefined)).toEqual(['admin', 'public', 'anonymous']);
|
||||
});
|
||||
|
||||
test('should return an array with the admin roles and the given roles', () => {
|
||||
expect(getAdminRoles(['anonymous', 'me', 'user', 'test_user'])).toEqual([
|
||||
'admin',
|
||||
'public',
|
||||
'anonymous',
|
||||
'me',
|
||||
'user',
|
||||
'test_user',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should return an array with the admin roles and the given roles without duplicates', () => {
|
||||
expect(getAdminRoles(['anonymous', 'me', 'user', 'admin'])).toEqual([
|
||||
'admin',
|
||||
'public',
|
||||
'anonymous',
|
||||
'me',
|
||||
'user',
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { isEmptyValue } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Adds the admin public and anonymous roles to the given list of roles with duplicates
|
||||
*
|
||||
* @param roles - Roles from auth.roles table in string format
|
||||
* @returns An array with the admin roles
|
||||
*/
|
||||
export default function getAdminRoles(roles?: string[]) {
|
||||
if (isEmptyValue(roles)) {
|
||||
return ['admin', 'public', 'anonymous'];
|
||||
}
|
||||
|
||||
const rolesSet = new Set(['admin', 'public', 'anonymous', ...roles]);
|
||||
|
||||
return Array.from(rolesSet);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { default as getAdminRoles } from './getAdminRoles';
|
||||
@@ -2,13 +2,10 @@ import { LoadingScreen } from '@/components/presentational/LoadingScreen';
|
||||
import { RetryableErrorBoundary } from '@/components/presentational/RetryableErrorBoundary';
|
||||
import { Button } from '@/components/ui/v2/Button';
|
||||
import { PlayIcon } from '@/components/ui/v2/icons/PlayIcon';
|
||||
import { Option } from '@/components/ui/v2/Option';
|
||||
import { Select } from '@/components/ui/v2/Select';
|
||||
import { Tooltip } from '@/components/ui/v2/Tooltip';
|
||||
import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
|
||||
import { UserSelect } from '@/features/orgs/projects/graphql/common/components/UserSelect';
|
||||
import { DEFAULT_ROLES } from '@/features/orgs/projects/graphql/common/utils/constants';
|
||||
import { UserAndRoleSelect } from '@/features/orgs/projects/graphql/common/components/UserAndRoleSelect';
|
||||
import { useProject } from '@/features/orgs/projects/hooks/useProject';
|
||||
import { triggerToast } from '@/utils/toast';
|
||||
import {
|
||||
@@ -41,11 +38,6 @@ interface GraphiQLHeaderProps {
|
||||
}
|
||||
|
||||
function GraphiQLHeader({ onUserChange, onRoleChange }: GraphiQLHeaderProps) {
|
||||
const [availableRoles, setAvailableRoles] = useState<string[]>(DEFAULT_ROLES);
|
||||
const [role, setRole] = useState<string>(() =>
|
||||
availableRoles.includes('user') ? 'user' : availableRoles[0],
|
||||
);
|
||||
|
||||
const copyQuery = useCopyQuery();
|
||||
const prettifyEditors = usePrettifyEditors();
|
||||
const {
|
||||
@@ -119,42 +111,10 @@ function GraphiQLHeader({ onUserChange, onRoleChange }: GraphiQLHeaderProps) {
|
||||
return (
|
||||
<header className="grid grid-flow-row items-end gap-2 p-2 md:grid-flow-col md:justify-between">
|
||||
<div className="grid grid-flow-row gap-2 md:grid-flow-col md:items-end">
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-flow-col md:grid-cols-[initial]">
|
||||
<UserSelect
|
||||
className="col-span-1 md:col-auto md:w-52"
|
||||
onUserChange={(userId, availableUserRoles) => {
|
||||
onUserChange(userId);
|
||||
setAvailableRoles(availableUserRoles);
|
||||
|
||||
const newRole = availableUserRoles.includes('user')
|
||||
? 'user'
|
||||
: availableUserRoles[0];
|
||||
|
||||
setRole(newRole);
|
||||
onRoleChange(newRole);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Select
|
||||
id="role-select"
|
||||
label="Role"
|
||||
value={role}
|
||||
onChange={(_event, value) => {
|
||||
if (typeof value === 'string') {
|
||||
setRole(value);
|
||||
onRoleChange(value);
|
||||
}
|
||||
}}
|
||||
hideEmptyHelperText
|
||||
className="col-span-1 md:col-auto md:w-52"
|
||||
>
|
||||
{availableRoles.map((availableRole) => (
|
||||
<Option value={availableRole} key={availableRole}>
|
||||
{availableRole}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<UserAndRoleSelect
|
||||
onUserChange={onUserChange}
|
||||
onRoleChange={onRoleChange}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 md:grid-flow-col md:grid-cols-[initial]">
|
||||
<Tooltip title="Prettify query (Shift+Ctrl+P)">
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
GetLogsSubscriptionDocument,
|
||||
useGetProjectLogsQuery,
|
||||
} from '@/utils/__generated__/graphql';
|
||||
import { MINUTES_TO_DECREASE_FROM_CURRENT_DATE } from '@/utils/constants/common';
|
||||
import { DEFAULT_LOG_INTERVAL } from '@/utils/constants/common';
|
||||
import { subMinutes } from 'date-fns';
|
||||
import {
|
||||
useCallback,
|
||||
@@ -37,7 +37,7 @@ export default function LogsPage() {
|
||||
const subscriptionReturn = useRef(null);
|
||||
|
||||
const [filters, setFilters] = useState<LogsFilters>({
|
||||
from: subMinutes(new Date(), MINUTES_TO_DECREASE_FROM_CURRENT_DATE),
|
||||
from: subMinutes(new Date(), DEFAULT_LOG_INTERVAL),
|
||||
to: new Date(),
|
||||
regexFilter: '',
|
||||
service: AvailableLogsService.ALL,
|
||||
@@ -48,6 +48,7 @@ export default function LogsPage() {
|
||||
error,
|
||||
subscribeToMore,
|
||||
client,
|
||||
refetch,
|
||||
loading: loadingLogs,
|
||||
} = useGetProjectLogsQuery({
|
||||
variables: { appID: project?.id, ...filters },
|
||||
@@ -147,6 +148,7 @@ export default function LogsPage() {
|
||||
<LogsHeader
|
||||
loading={loading}
|
||||
onSubmitFilterValues={onSubmitFilterValues}
|
||||
onRefetch={refetch}
|
||||
/>
|
||||
<LogsBody error={error} loading={loading} logsData={data} />
|
||||
</RetryableErrorBoundary>
|
||||
|
||||
@@ -78,7 +78,10 @@ export default function SettingsGeneralPage() {
|
||||
usePauseApplicationMutation({
|
||||
variables: { appId: project?.id },
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -86,7 +89,10 @@ export default function SettingsGeneralPage() {
|
||||
useUnpauseApplicationMutation({
|
||||
variables: { appId: project?.id },
|
||||
refetchQueries: [
|
||||
{ query: GetOrganizationsDocument, variables: { userId: userData.id } },
|
||||
{
|
||||
query: GetOrganizationsDocument,
|
||||
variables: { userId: userData?.id },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ProjectLayout } from '@/features/orgs/layout/ProjectLayout';
|
||||
import { useOrgs } from '@/features/orgs/projects/hooks/useOrgs';
|
||||
import { execPromiseWithErrorToast } from '@/features/orgs/utils/execPromiseWithErrorToast';
|
||||
import { useSubmitState } from '@/hooks/useSubmitState';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import {
|
||||
useInsertOrgApplicationMutation,
|
||||
usePrefetchNewAppQuery,
|
||||
@@ -113,6 +114,15 @@ export function NewProjectPageContent({
|
||||
});
|
||||
|
||||
if (subdomain) {
|
||||
analytics.track('Project Created', {
|
||||
projectName: name,
|
||||
projectSlug: slug,
|
||||
organizationId: selectedOrg.id,
|
||||
organizationName: selectedOrg.name,
|
||||
regionId: selectedRegion.id,
|
||||
regionName: selectedRegion.name,
|
||||
});
|
||||
|
||||
await router.push(`/orgs/${selectedOrg.slug}/projects/${subdomain}`);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
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 { analytics } from '@/lib/segment';
|
||||
import type { PostOrganizationRequestMutation } from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus } from '@nhost/nextjs';
|
||||
import { useGetOrganizationLazyQuery } from '@/utils/__generated__/graphql';
|
||||
import { useAuthenticationStatus, useUserData } from '@nhost/nextjs';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
@@ -14,6 +15,8 @@ export default function PostCheckout() {
|
||||
const router = useRouter();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { isAuthenticated, isLoading } = useAuthenticationStatus();
|
||||
const currentUser = useUserData();
|
||||
const [getOrganizations] = useGetOrganizationLazyQuery();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPlatform || isLoading || isAuthenticated) {
|
||||
@@ -24,24 +27,38 @@ export default function PostCheckout() {
|
||||
}, [isLoading, isAuthenticated, router, isPlatform]);
|
||||
|
||||
const onCompleted = useCallback(
|
||||
(
|
||||
async (
|
||||
data: PostOrganizationRequestMutation['billingPostOrganizationRequest'],
|
||||
) => {
|
||||
const { Slug } = data;
|
||||
|
||||
const { data: orgData } = await getOrganizations({
|
||||
variables: {
|
||||
orgSlug: Slug,
|
||||
},
|
||||
});
|
||||
|
||||
const { id, name, slug, plan } = orgData.organizations[0];
|
||||
analytics.track('Organization Created', {
|
||||
organizationId: id,
|
||||
organizationSlug: slug,
|
||||
organizationName: name,
|
||||
organizationPlan: plan?.name,
|
||||
organizationOwnerId: currentUser?.id,
|
||||
organizationOwnerEmail: currentUser?.email,
|
||||
});
|
||||
|
||||
router.push(`/orgs/${Slug}/projects`);
|
||||
},
|
||||
[router],
|
||||
[router, currentUser?.email, currentUser?.id, getOrganizations],
|
||||
);
|
||||
|
||||
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..."
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Divider } from '@/components/ui/v2/Divider';
|
||||
import { GitHubIcon } from '@/components/ui/v2/icons/GitHubIcon';
|
||||
import { Input, inputClasses } from '@/components/ui/v2/Input';
|
||||
import { Text } from '@/components/ui/v2/Text';
|
||||
import { analytics } from '@/lib/segment';
|
||||
import { getToastStyleProps } from '@/utils/constants/settings';
|
||||
import { nhost } from '@/utils/nhost';
|
||||
import { yupResolver } from '@hookform/resolvers/yup';
|
||||
@@ -39,6 +40,19 @@ export default function SignUpPage() {
|
||||
const { signUpEmailPassword, error } = useSignUpEmailPassword();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const [anonId, setAnonId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const getAnonId = async () => {
|
||||
try {
|
||||
const user = await analytics.user();
|
||||
setAnonId(user.anonymousId());
|
||||
} catch (err) {
|
||||
console.error('Failed to get anonymous ID:', err);
|
||||
}
|
||||
};
|
||||
getAnonId();
|
||||
}, []);
|
||||
|
||||
// x-cf-turnstile-response
|
||||
const [turnstileResponse, setTurnstileResponse] = useState(null);
|
||||
@@ -85,6 +99,7 @@ export default function SignUpPage() {
|
||||
password,
|
||||
{
|
||||
displayName,
|
||||
metadata: { anonId },
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
@@ -126,7 +141,10 @@ export default function SignUpPage() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await nhost.auth.signIn({ provider: 'github' });
|
||||
await nhost.auth.signIn({
|
||||
provider: 'github',
|
||||
options: { metadata: { anonId } },
|
||||
});
|
||||
} catch {
|
||||
toast.error(
|
||||
`An error occurred while trying to sign up using GitHub. Please try again.`,
|
||||
|
||||
@@ -67,4 +67,4 @@ export const MAX_FREE_PROJECTS = 1;
|
||||
/**
|
||||
* Default value in minutes to use for querying the logs
|
||||
*/
|
||||
export const MINUTES_TO_DECREASE_FROM_CURRENT_DATE = 20;
|
||||
export const DEFAULT_LOG_INTERVAL = 15;
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# @nhost/docs
|
||||
|
||||
## 2.31.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- b302dbd: feat: added sveltekit quickstart
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 5e96230: fix: fixing mintlify breaking our docs
|
||||
|
||||
## 2.30.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
"$schema": "https://mintlify.com/docs.json",
|
||||
"theme": "mint",
|
||||
"name": "Documentation",
|
||||
"integrations": {
|
||||
"segment": {
|
||||
"key": "kD6QfDOMGR2IoJ9D1U1H5Q9X7AEjoVfN"
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"primary": "#3787ff",
|
||||
"light": "#569aff",
|
||||
@@ -31,6 +36,7 @@
|
||||
"/getting-started/quickstart/react",
|
||||
"/getting-started/quickstart/nextjs",
|
||||
"/getting-started/quickstart/vue",
|
||||
"/getting-started/quickstart/sveltekit",
|
||||
"/getting-started/quickstart/reactnative"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ It eliminates backend complexity by providing a performant and reliable software
|
||||
src="/images/nhost-overview-dark.svg"
|
||||
alt="Hero Dark"
|
||||
/>
|
||||
|
||||
|
||||
|
||||
## Next steps
|
||||
|
||||
@@ -47,9 +47,16 @@ Follow one of our quick start guides for learning how to quickly setup Nhost wit
|
||||
>
|
||||
Learn how to connect Nhost with Vue
|
||||
</Card>
|
||||
<Card
|
||||
title="SvelteKit"
|
||||
icon="s"
|
||||
href="/getting-started/tutorials/sveltekit"
|
||||
>
|
||||
Learn how to connect Nhost with SvelteKit
|
||||
</Card>
|
||||
<Card
|
||||
title="React Native"
|
||||
icon="react-native"
|
||||
icon="mobile-notch"
|
||||
href="/getting-started/quickstart/reactnative"
|
||||
>
|
||||
Learn how to connect Nhost with React Native
|
||||
@@ -62,6 +69,13 @@ Follow one of our quick start guides for learning how to quickly setup Nhost wit
|
||||
Follow one of your tutorials where we walk you through building a Todo Manager application using features from Nhost.
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
title="Next.js"
|
||||
icon="react"
|
||||
href="/getting-started/tutorials/nextjs"
|
||||
>
|
||||
Todo Manager with Nhost and NextJS
|
||||
</Card>
|
||||
<Card
|
||||
title="React"
|
||||
icon="react"
|
||||
@@ -76,13 +90,6 @@ Follow one of your tutorials where we walk you through building a Todo Manager a
|
||||
>
|
||||
Todo Manager with Nhost and Vue
|
||||
</Card>
|
||||
<Card
|
||||
title="Next.js"
|
||||
icon="image"
|
||||
href="/getting-started/tutorials/nextjs"
|
||||
>
|
||||
Todo Manager with Nhost and NextJS
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
|
||||
133
docs/getting-started/quickstart/sveltekit.mdx
Normal file
133
docs/getting-started/quickstart/sveltekit.mdx
Normal file
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: Setup Nhost with SvelteKit
|
||||
sidebarTitle: SvelteKit
|
||||
description: Get up and running with Nhost and SvelteKit
|
||||
icon: s
|
||||
---
|
||||
|
||||
<Steps>
|
||||
<Step title="Create Project">
|
||||
If you haven't, please create a project through the [Nhost Dashboard](https://app.nhost.io).
|
||||
</Step>
|
||||
|
||||
<Step title="Setup Database">
|
||||
Navigate to the **SQL Editor** of the database and run the following SQL to create a new table `movies` with some great movies.
|
||||
|
||||
```sql SQL Editor
|
||||
CREATE TABLE movies (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
director VARCHAR(255),
|
||||
release_year INTEGER,
|
||||
genre VARCHAR(100),
|
||||
rating FLOAT
|
||||
);
|
||||
|
||||
INSERT INTO movies (title, director, release_year, genre, rating) VALUES
|
||||
('Inception', 'Christopher Nolan', 2010, 'Sci-Fi', 8.8),
|
||||
('The Godfather', 'Francis Ford Coppola', 1972, 'Crime', 9.2),
|
||||
('Forrest Gump', 'Robert Zemeckis', 1994, 'Drama', 8.8),
|
||||
('The Matrix', 'Lana Wachowski, Lilly Wachowski', 1999, 'Action', 8.7);
|
||||
```
|
||||
|
||||
<Warning>Make sure the option `Track this` is enabled</Warning>
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="permissions">
|
||||
Select the new table `movies` just created, and click in **Edit Permissions** to set the following permissions for the `public` role and `select` action.
|
||||
|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Setup a SvelteKit Application">
|
||||
Create a SvelteKit application.
|
||||
|
||||
```bash Terminal
|
||||
mkdir nhost-sveltekit-quickstart && \
|
||||
cd nhost-sveltekit-quickstart && \
|
||||
npx sv create --template minimal --no-types --no-add-ons --install npm
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Install the Nhost package for SvelteKit">
|
||||
Navigate to the SvelteKit application and install `@nhost/nhost-js`.
|
||||
|
||||
```bash Terminal
|
||||
npm install @nhost/nhost-js
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Configure the Nhost client and fetch the list of movies">
|
||||
|
||||
Create a new file with the following code to creates the Nhost client.
|
||||
|
||||
```js ./src/lib/nhost.js
|
||||
import { NhostClient } from "@nhost/nhost-js";
|
||||
|
||||
export const nhost = new NhostClient({
|
||||
subdomain: "<subdomain>",
|
||||
region: "<region>",
|
||||
})
|
||||
```
|
||||
|
||||
<Note>Replace `<subdomain>` and `<region>` with the subdomain and region for the project</Note>
|
||||
|
||||
Finally, update `src/routes/+page.svelte` to fetch the list of movies.
|
||||
|
||||
```js src/routes/+page.svelte
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { nhost } from '../lib/nhost';
|
||||
|
||||
let loading = true;
|
||||
let movies = [];
|
||||
|
||||
const getMovies = `
|
||||
query {
|
||||
movies {
|
||||
title
|
||||
genre
|
||||
rating
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
onMount(async () => {
|
||||
const { data, error } = await nhost.graphql.request(getMovies);
|
||||
if (!error) {
|
||||
movies = data.movies;
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else}
|
||||
<table>
|
||||
<tbody>
|
||||
{#each movies as movie}
|
||||
<tr>
|
||||
<td>{movie.title}</td>
|
||||
<td>{movie.genre}</td>
|
||||
<td>{movie.rating}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="The end">
|
||||
Run your project with `npm run dev` and navigate to `http://localhost:5173` in your browser.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "@nhost/docs",
|
||||
"version": "2.30.0",
|
||||
"version": "2.31.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "mintlify dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"mintlify": "^4.0.445"
|
||||
"mintlify": "^4.0.476"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ mode: "custom"
|
||||
|
||||
<div className="welcome-page">
|
||||
<div className="welcome-hero">
|
||||
# Platform
|
||||
<h1>Platform</h1>
|
||||
<p>Learn about platform features</p>
|
||||
<b>
|
||||
[Explore Nhost Cloud →](/platform/cloud)
|
||||
@@ -13,7 +13,7 @@ mode: "custom"
|
||||
</div>
|
||||
|
||||
<div className="welcome-get-started">
|
||||
# Explore different deployment options
|
||||
<h1>Explore different deployment options</h1>
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card
|
||||
|
||||
@@ -6,7 +6,7 @@ mode: "custom"
|
||||
|
||||
<div className="welcome-page">
|
||||
<div className="welcome-hero">
|
||||
# Products
|
||||
<h1>Products</h1>
|
||||
<p>Turn-key convenience plus extensibility</p>
|
||||
<b>
|
||||
[Explore products →](/products/database/overview)
|
||||
@@ -14,7 +14,7 @@ mode: "custom"
|
||||
</div>
|
||||
|
||||
<div className="welcome-get-started">
|
||||
# Nhost Stack
|
||||
<h1>Nhost Stack</h1>
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card
|
||||
@@ -51,7 +51,7 @@ mode: "custom"
|
||||
|
||||
</CardGroup>
|
||||
|
||||
# Nhost Extend
|
||||
<h1>Nhost Extend</h1>
|
||||
<CardGroup cols={3}>
|
||||
<Card
|
||||
title="Run"
|
||||
|
||||
@@ -5,7 +5,7 @@ mode: "custom"
|
||||
|
||||
<div className="welcome-page">
|
||||
<div className="welcome-hero">
|
||||
# Build. Deploy. Scale.
|
||||
<h1>Build. Deploy. Scale.</h1>
|
||||
<p>Learn how to get started with Nhost</p>
|
||||
<b>
|
||||
[Explore the docs →](/getting-started)
|
||||
@@ -13,7 +13,7 @@ mode: "custom"
|
||||
</div>
|
||||
|
||||
<div className="welcome-get-started">
|
||||
# Get started with our guides or explore our community
|
||||
<h1>Get started with our guides or explore our community</h1>
|
||||
|
||||
<CardGroup cols={3}>
|
||||
<Card
|
||||
@@ -59,7 +59,7 @@ mode: "custom"
|
||||
Deployable applications built with Nhost
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost-examples/cli
|
||||
|
||||
## 0.3.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@3.2.8
|
||||
|
||||
## 0.3.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/cli",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.21",
|
||||
"main": "src/index.mjs",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @nhost-examples/codegen-react-apollo
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/react@3.10.4
|
||||
- @nhost/react-apollo@17.0.4
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/codegen-react-apollo",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"codegen": "graphql-codegen",
|
||||
@@ -36,6 +36,6 @@
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^5.4.17"
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @nhost-examples/codegen-react-query
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
## 0.7.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/react@3.10.4
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/codegen-react-query",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"codegen": "graphql-codegen",
|
||||
@@ -37,6 +37,6 @@
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^5.4.17"
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# @nhost-examples/react-urql
|
||||
|
||||
## 0.7.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
## 0.6.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/react@3.10.4
|
||||
- @nhost/react-urql@14.0.4
|
||||
|
||||
## 0.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@nhost-examples/codegen-react-urql",
|
||||
"private": true,
|
||||
"version": "0.6.0",
|
||||
"version": "0.7.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
@@ -30,6 +30,6 @@
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^5.4.17"
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost-examples/multi-tenant-one-to-many
|
||||
|
||||
## 2.2.22
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@3.2.8
|
||||
|
||||
## 2.2.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@nhost-examples/multi-tenant-one-to-many",
|
||||
"private": true,
|
||||
"version": "2.2.21",
|
||||
"version": "2.2.22",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# @nhost-examples/nextjs
|
||||
|
||||
## 0.4.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- fad7f64: chore: fix typo
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- Updated dependencies [d9eb906]
|
||||
- @nhost/nextjs@2.2.7
|
||||
- @nhost/react@3.10.4
|
||||
- @nhost/react-apollo@17.0.4
|
||||
|
||||
## 0.4.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/nextjs",
|
||||
"version": "0.4.6",
|
||||
"version": "0.4.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@nhost/react": "workspace:^",
|
||||
"@nhost/react-apollo": "workspace:^",
|
||||
"graphql": "16.8.1",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.26",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react-icons": "^4.12.0"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NextPage } from 'next'
|
||||
import { Container, Title } from '@mantine/core'
|
||||
import { useAccessToken, useAuthenticated } from '@nhost/nextjs'
|
||||
|
||||
const PublicSSRPage: NextPage = () => {
|
||||
const PublicCSRPage: NextPage = () => {
|
||||
const isAuthenticated = useAuthenticated()
|
||||
const accessToken = useAccessToken()
|
||||
return (
|
||||
@@ -15,4 +15,4 @@ const PublicSSRPage: NextPage = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export default PublicSSRPage
|
||||
export default PublicCSRPage
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# @nhost-examples/node-storage
|
||||
|
||||
## 0.2.21
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/nhost-js@3.2.8
|
||||
|
||||
## 0.2.20
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -8,7 +8,7 @@ Make sure to install the dependencies:
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Settting up the environment
|
||||
## Setting up the environment
|
||||
|
||||
Create a `.env` file in the root of the project with the following content:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/node-storage",
|
||||
"version": "0.2.20",
|
||||
"version": "0.2.21",
|
||||
"private": true,
|
||||
"description": "This is an example of how to use the Storage with Node.js",
|
||||
"main": "src/index.mjs",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost-examples/nextjs-server-components
|
||||
|
||||
## 0.5.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/nhost-js@3.2.8
|
||||
|
||||
## 0.5.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/nextjs-server-components",
|
||||
"version": "0.5.5",
|
||||
"version": "0.5.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -18,7 +18,7 @@
|
||||
"form-data": "^4.0.0",
|
||||
"graphql": "16.8.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "^14.2.25",
|
||||
"next": "^14.2.26",
|
||||
"postcss": "^8.4.38",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[global]
|
||||
|
||||
[hasura]
|
||||
version = 'v2.25.1-ce'
|
||||
version = 'v2.46.0-ce'
|
||||
adminSecret = '{{ secrets.HASURA_GRAPHQL_ADMIN_SECRET }}'
|
||||
webhookSecret = '{{ secrets.NHOST_WEBHOOK_SECRET }}'
|
||||
|
||||
@@ -25,10 +25,10 @@ httpPoolSize = 100
|
||||
|
||||
[functions]
|
||||
[functions.node]
|
||||
version = 18
|
||||
version = 22
|
||||
|
||||
[auth]
|
||||
version = '0.24.0'
|
||||
version = '0.38.0'
|
||||
|
||||
[auth.redirections]
|
||||
clientUrl = 'http://localhost:3000'
|
||||
@@ -139,12 +139,15 @@ timeout = 60000
|
||||
enabled = false
|
||||
|
||||
[postgres]
|
||||
version = '14.6-20230406-2'
|
||||
version = '16.6-20250311-1'
|
||||
|
||||
[postgres.resources.storage]
|
||||
capacity = 1
|
||||
|
||||
[provider]
|
||||
|
||||
[storage]
|
||||
version = '0.5.1'
|
||||
version = '0.7.1'
|
||||
|
||||
[observability]
|
||||
[observability.grafana]
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
---
|
||||
|
||||
## 0.8.0
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
## 0.7.1
|
||||
### Patch Changes
|
||||
|
||||
- f8243f9: chore (examples/svelte): update @sveltejs/kit
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/nhost-js@3.2.8
|
||||
|
||||
## 0.7.0
|
||||
### Minor Changes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/sveltekit",
|
||||
"version": "0.7.0",
|
||||
"version": "0.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -16,7 +16,7 @@
|
||||
"@playwright/test": "^1.41.0",
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/adapter-vercel": "^5.6.3",
|
||||
"@sveltejs/kit": "^2.11.1",
|
||||
"@sveltejs/kit": "^2.20.6",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"autoprefixer": "^10.4.19",
|
||||
@@ -30,7 +30,7 @@
|
||||
"svelte-check": "^3.6.8",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^5.4.3",
|
||||
"vite": "^6.0.14",
|
||||
"vite": "^6.2.7",
|
||||
"vitest": "^0.25.8"
|
||||
},
|
||||
"type": "module",
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# @nhost-examples/react-apollo
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- 97db637: fix: fix settings
|
||||
|
||||
## 1.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- efd68c3: chore (react-apollo): use preview build instead of local dev server for e2e tests
|
||||
- @nhost/react@3.10.4
|
||||
- @nhost/react-apollo@17.0.4
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -47,27 +47,6 @@ disableNewUsers = false
|
||||
default = 'user'
|
||||
allowed = ['user', 'me']
|
||||
|
||||
[auth.rateLimit]
|
||||
[auth.rateLimit.emails]
|
||||
limit = 100
|
||||
interval = '1h'
|
||||
|
||||
[auth.rateLimit.sms]
|
||||
limit = 100
|
||||
interval = '1h'
|
||||
|
||||
[auth.rateLimit.bruteForce]
|
||||
limit = 100
|
||||
interval = '5m'
|
||||
|
||||
[auth.rateLimit.signups]
|
||||
limit = 100
|
||||
interval = '5m'
|
||||
|
||||
[auth.rateLimit.global]
|
||||
limit = 1000
|
||||
interval = '1m'
|
||||
|
||||
[auth.user.locale]
|
||||
default = 'en'
|
||||
allowed = ['en']
|
||||
@@ -182,14 +161,6 @@ version = '16.6-20250311-1'
|
||||
capacity = 1
|
||||
|
||||
[provider]
|
||||
[provider.smtp]
|
||||
host = "smtp.test.com"
|
||||
method = "LOGIN"
|
||||
password = "test123123"
|
||||
port = 587
|
||||
secure = false
|
||||
sender = "test@nhost.io"
|
||||
user = "test"
|
||||
|
||||
[storage]
|
||||
version = '0.7.1'
|
||||
|
||||
@@ -35,6 +35,32 @@
|
||||
"op": "replace",
|
||||
"path": "/auth/method/webauthn/relyingParty/origins/0"
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"bruteForce": {
|
||||
"interval": "5m",
|
||||
"limit": 100
|
||||
},
|
||||
"emails": {
|
||||
"interval": "1h",
|
||||
"limit": 100
|
||||
},
|
||||
"global": {
|
||||
"interval": "1m",
|
||||
"limit": 1000
|
||||
},
|
||||
"signups": {
|
||||
"interval": "5m",
|
||||
"limit": 100
|
||||
},
|
||||
"sms": {
|
||||
"interval": "1h",
|
||||
"limit": 100
|
||||
}
|
||||
},
|
||||
"op": "add",
|
||||
"path": "/auth/rateLimit"
|
||||
},
|
||||
{
|
||||
"value": "http://localhost:3000",
|
||||
"op": "replace",
|
||||
@@ -77,5 +103,18 @@
|
||||
"value": "http://localhost:3000",
|
||||
"op": "replace",
|
||||
"path": "/auth/redirections/clientUrl"
|
||||
},
|
||||
{
|
||||
"value": {
|
||||
"host": "smtp.test.com",
|
||||
"method": "LOGIN",
|
||||
"password": "test123123",
|
||||
"port": 587,
|
||||
"secure": false,
|
||||
"sender": "test@nhost.io",
|
||||
"user": "test"
|
||||
},
|
||||
"op": "add",
|
||||
"path": "/provider/smtp"
|
||||
}
|
||||
]
|
||||
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "@nhost-examples/react-apollo",
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"preview": "vite preview --port 3000",
|
||||
"build:preview": "pnpm build && pnpm preview",
|
||||
"install-browsers": "pnpm playwright install && pnpm playwright install-deps",
|
||||
"e2e": "pnpm e2e:start-backend && pnpm e2e:test",
|
||||
"e2e:test": "pnpm install-browsers && pnpm playwright test",
|
||||
@@ -37,7 +38,7 @@
|
||||
"react-dom": "^18.2.0",
|
||||
"react-dropzone": "^14.2.3",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-router-dom": "^7.5.2",
|
||||
"sonner": "^1.5.0",
|
||||
"tailwind-merge": "^1.14.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -63,6 +64,6 @@
|
||||
"totp-generator": "^0.0.13",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
"vite": "^5.4.17"
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,11 @@ export default defineConfig({
|
||||
timeout: 5000
|
||||
},
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
command: 'pnpm build:preview',
|
||||
port: 3000
|
||||
},
|
||||
use: {
|
||||
trace: 'on-first-retry',
|
||||
trace: 'retain-on-failure',
|
||||
baseURL: 'http://localhost:3000'
|
||||
},
|
||||
fullyParallel: true,
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# @nhost-examples/react-gqty
|
||||
|
||||
## 1.6.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
## 1.5.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/react@3.10.4
|
||||
|
||||
## 1.5.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@nhost-examples/react-gqty",
|
||||
"private": true,
|
||||
"version": "1.5.0",
|
||||
"version": "1.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,6 +27,6 @@
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"typescript": "^4.9.5",
|
||||
"vite": "^5.4.17"
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# @nhost-examples/react-native
|
||||
|
||||
## 0.1.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @nhost/react@3.10.4
|
||||
- @nhost/react-apollo@17.0.4
|
||||
|
||||
## 0.1.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@nhost-examples/react-native",
|
||||
"version": "0.1.7",
|
||||
"version": "0.1.8",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"android": "react-native run-android",
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# @nhost-examples/vue-apollo
|
||||
|
||||
## 0.12.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- c97b43f: fix: update vite to address vulnerability audit
|
||||
|
||||
## 0.11.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- d9eb906: fix: update vite and nextjs because of vulnerability
|
||||
- @nhost/nhost-js@3.2.8
|
||||
- @nhost/apollo@8.0.8
|
||||
- @nhost/vue@2.9.5
|
||||
|
||||
## 0.11.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@nhost-examples/vue-apollo",
|
||||
"private": true,
|
||||
"version": "0.11.0",
|
||||
"version": "0.12.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -37,7 +37,7 @@
|
||||
"@xstate/inspect": "^0.6.5",
|
||||
"sass": "1.86.1",
|
||||
"typescript": "4.9.4",
|
||||
"vite": "^5.4.17",
|
||||
"vite": "^5.4.19",
|
||||
"vue-tsc": "^0.38.9"
|
||||
},
|
||||
"eslintConfig": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user